diff --git a/main.go b/main.go index 7816d04..5e4907c 100644 --- a/main.go +++ b/main.go @@ -14,10 +14,9 @@ import ( "github.com/charmbracelet/bubbles/progress" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" "github.com/charmbracelet/lipgloss" "github.com/urfave/cli/v2" - - "github.com/charmbracelet/huh" ) type screen int @@ -34,12 +33,19 @@ const ( ) type model struct { + // View state form *huh.Form progressBar progress.Model state string intervalNum int - remaining time.Duration currentScreen screen + isFocus bool + fullscreen bool + width int + height int + + // Timer state + remaining time.Duration intervals int breakTime time.Duration focusTime time.Duration @@ -47,15 +53,28 @@ type model struct { onFocusStart []string onFocusEnd []string onIntervalEnd []string - isFocus bool - fullscreen bool - width int - height int err error shellrunner *tortoise.ShellRunner } -func initialModel(fullscreen bool, colorLeft string, colorRight string, defaultFocusTime, defaultBreakTime, defaultIntervals *string) model { +// Message that signals to start the timer +type startTimerMsg struct{} + +// Command that triggers start message +func startTimer() tea.Msg { + return startTimerMsg{} +} + +// Message that signals to get values from the form +type formSubmitMsg struct{} + +// Command that triggers form submit message +func formSubmit() tea.Msg { + return formSubmitMsg{} +} + +// Create input form with default values if provided +func createInputForm(defaultFocusTime, defaultBreakTime, defaultIntervals *string) *huh.Form { // Ceate validation functions for input validateDuration := func(text string) error { _, err := parseDuration(text) @@ -70,10 +89,6 @@ func initialModel(fullscreen bool, colorLeft string, colorRight string, defaultF return nil } - if colorLeft == "" || colorRight == "" { - slog.Panicf("Color flags can't be empty") - } - // Create form fields focusTime := huh.NewInput(). Key(FORM_FOCUS). @@ -108,8 +123,19 @@ func initialModel(fullscreen bool, colorLeft string, colorRight string, defaultF intervals = intervals.Value(defaultIntervals) } + f := huh.NewForm(huh.NewGroup(focusTime, breakTime, intervals)).WithShowErrors(true) + f.SubmitCmd = formSubmit + + return f +} + +func newModelBasic(fullscreen bool, colorLeft, colorRight string) model { + if colorLeft == "" || colorRight == "" { + slog.Panicf("Color flags can't be empty") + } + return model{ - form: huh.NewForm(huh.NewGroup(focusTime, breakTime, intervals)).WithShowErrors(true), + form: nil, progressBar: progress.New(progress.WithScaledGradient(colorLeft, colorRight)), state: "stopped", intervalNum: 1, @@ -121,11 +147,51 @@ func initialModel(fullscreen bool, colorLeft string, colorRight string, defaultF } } +func newModel(fullscreen bool, colorLeft, colorRight string, focusTime, breakTime time.Duration, intervals int, onFocusStart, onFocusEnd, onIntervalEnd []string) model { + m := newModelBasic(fullscreen, colorLeft, colorRight) + + if focusTime != 0 && breakTime != 0 && intervals != 0 { + // All values provided, initialize timer + m.focusTime = focusTime + m.breakTime = breakTime + m.intervals = intervals + m.currentScreen = TIMER_SCREEN + } else { + // Prompt for input + var defaultFocusTime, defaultBreakTime, defaultIntervals *string + + if value := focusTime.String(); value != "0s" { + defaultFocusTime = &value + } + + if value := breakTime.String(); value != "0s" { + defaultBreakTime = &value + } + + if intervals != 0 { + intervalsString := strconv.Itoa(intervals) + defaultIntervals = &intervalsString + } + + m.form = createInputForm(defaultFocusTime, defaultBreakTime, defaultIntervals) + m.currentScreen = INPUT_SCREEN + } + + // Collect command flags + m.onFocusStart = onFocusStart + m.onFocusEnd = onFocusEnd + m.onIntervalEnd = onIntervalEnd + + return m +} + func (m model) Init() tea.Cmd { // Handle Ctrl+C for graceful exit c := make(chan os.Signal, 1) signal.Notify(c, os.Interrupt) + cmds := []tea.Cmd{tea.WindowSize(), textinput.Blink} + go func() { <-c @@ -135,10 +201,20 @@ func (m model) Init() tea.Cmd { os.Exit(0) }() - m.form.Init() - m.shellrunner.Start() + if m.form != nil { + // Start input form if it exists + cmds = append(cmds, m.form.Init()) + } else { + // Start timer if no form + cmds = append(cmds, startTimer) + } - return tea.Batch(tea.WindowSize(), textinput.Blink) + // Start shell runner if there are any commands to run + if len(m.onFocusStart) > 0 || len(m.onFocusEnd) > 0 || len(m.onIntervalEnd) > 0 { + m.shellrunner.Start() + } + + return tea.Batch(cmds...) } // totalTime returns the total time for the current period (focus or break) @@ -154,52 +230,13 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds := []tea.Cmd{} // Handle input screen - if m.currentScreen == INPUT_SCREEN { + if m.currentScreen == INPUT_SCREEN && m.form != nil { form, cmd := m.form.Update(msg) if f, ok := form.(*huh.Form); ok { m.form = f } cmds = append(cmds, cmd) - - if m.form.State == huh.StateCompleted { - // Kick off the timer - focusTime, err := parseDuration(m.form.GetString(FORM_FOCUS)) - if err != nil { - m.err = fmt.Errorf("error parsing focus time duration: %w", err) - slog.Fatalf("Error parsing focus time: %v", err) - - return m, tea.Quit - } - - breakTime, err := parseDuration(m.form.GetString(FORM_BREAK)) - if err != nil { - m.err = fmt.Errorf("error parsing break time duration: %w", err) - slog.Fatalf("Error parsing break time: %v", err) - - return m, tea.Quit - } - - m.intervals, err = strconv.Atoi(m.form.GetString(FORM_INTERVALS)) - if err != nil { - m.err = fmt.Errorf("error parsing interval: %w", err) - slog.Fatalf("Error parsing interval: %v", err) - - return m, tea.Quit - } - - m.focusTime = focusTime - m.breakTime = breakTime - m.remaining = focusTime - m.state = "Focus" - m.currentScreen = TIMER_SCREEN - m.startTime = time.Now() - - // Run onFocusStart commands - m.startCommands(m.onFocusStart) - - return m, tick() - } } // Handle any uncaptured input screen updates @@ -217,9 +254,52 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } + case formSubmitMsg: + // Get values from the form and send start timer command + var err error + + m.focusTime, err = parseDuration(m.form.GetString(FORM_FOCUS)) + if err != nil { + m.err = fmt.Errorf("error parsing focus time duration: %w", err) + slog.Fatalf("Error parsing focus time: %v", err) + + return m, tea.Quit + } + + m.breakTime, err = parseDuration(m.form.GetString(FORM_BREAK)) + if err != nil { + m.err = fmt.Errorf("error parsing break time duration: %w", err) + slog.Fatalf("Error parsing break time: %v", err) + + return m, tea.Quit + } + + m.intervals, err = strconv.Atoi(m.form.GetString(FORM_INTERVALS)) + if err != nil { + m.err = fmt.Errorf("error parsing interval: %w", err) + slog.Fatalf("Error parsing interval: %v", err) + + return m, tea.Quit + } + + return m, startTimer + + case startTimerMsg: + // Start the timer + m.currentScreen = TIMER_SCREEN + // TODO: Could maybe set startTime to 0 and then send a tick command to start the timer + // since much of this is duplicate of below + m.startTime = time.Now() + m.remaining = m.focusTime + m.state = "Focus" + m.startCommands(m.onFocusStart) + + return m, tick() + case timeMsg: // Handle timer update for each second m.remaining = m.totalTime() - time.Since(m.startTime) + // Check if we've reached a new period if m.remaining < 0 { if m.isFocus { // Focus period ends, switch to break @@ -257,6 +337,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.height = msg.Height } + // Get errors from shellrunner for { result := m.shellrunner.GetResults() if result == nil { @@ -304,6 +385,10 @@ func (m model) View() string { // View for input screen func (m model) inputScreenView() string { + if m.form == nil { + return "Loading..." + } + var builder strings.Builder builder.WriteString("Enter your Pomodoro settings:\n\n") @@ -415,34 +500,17 @@ func main() { return nil } - // Set focus, break, and interval values if provided - var defaultFocusTime, defaultBreakTime, defaultIntervals *string - - if focusTime := c.Duration("focus").String(); focusTime != "0s" { - defaultFocusTime = &focusTime - } - if breakTime := c.Duration("break").String(); breakTime != "0s" { - defaultBreakTime = &breakTime - } - if intervals := c.Int("intervals"); intervals != 0 { - intervalsString := strconv.Itoa(intervals) - defaultIntervals = &intervalsString - } - - m := initialModel( + m := newModel( c.Bool("fullscreen"), c.String("color-left"), c.String("color-right"), - defaultFocusTime, - defaultBreakTime, - defaultIntervals, + c.Duration("focus"), + c.Duration("break"), + c.Int("intervals"), + c.StringSlice("on-focus-start"), + c.StringSlice("on-focus-end"), + c.StringSlice("on-interval-end"), ) - - // Collect command flags - m.onFocusStart = c.StringSlice("on-focus-start") - m.onFocusEnd = c.StringSlice("on-focus-end") - m.onIntervalEnd = c.StringSlice("on-interval-end") - // Start tea program options := []tea.ProgramOption{} diff --git a/main_test.go b/main_test.go index 0bb18c5..ca7eef4 100644 --- a/main_test.go +++ b/main_test.go @@ -1,6 +1,10 @@ package main import ( + "fmt" + "reflect" + "runtime" + "strings" "testing" "time" @@ -60,23 +64,45 @@ func TestParseDuration(t *testing.T) { // TestRunCommands tests the runCommands function func TestRunCommands(t *testing.T) { - m := initialModel(false, "#ffdd57", "#57ddff", nil, nil, nil) + m := newModelBasic(false, "#ffdd57", "#57ddff") + m.onFocusStart = []string{"echo Focus Start"} + m.Init() - - m.startCommands([]string{"echo Hello, World!"}) - - m.shellrunner.Stop() + m.startCommands(m.onFocusStart) } -func sendKeys(m model, keys ...tea.KeyType) (model, tea.Cmd) { +func keyMsgs(keys ...interface{}) []tea.Msg { + keyMessages := []tea.Msg{} + + for _, key := range keys { + switch keyType := key.(type) { + case tea.KeyType: + keyMessages = append(keyMessages, tea.KeyMsg{Type: keyType}) + case string: + for _, r := range key.(string) { + keyMessages = append(keyMessages, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) + } + default: + panic(fmt.Sprintf("Unknown key type: %v", key)) + } + } + + return keyMessages +} + +func sendKeys(m model, keys ...interface{}) (model, tea.Cmd) { var updatedModel tea.Model var cmd tea.Cmd - for _, key := range keys { - updatedModel, cmd = m.Update(tea.KeyMsg{Type: key}) + for _, key := range keyMsgs(keys...) { + updatedModel, cmd = m.Update(key) m = updatedModel.(model) - m.form.UpdateFieldPositions() + + if cmd != nil { + updatedModel, cmd = m.Update(cmd()) + m = updatedModel.(model) + } } return m, cmd @@ -84,44 +110,70 @@ func sendKeys(m model, keys ...tea.KeyType) (model, tea.Cmd) { // TestInputView tests the Update method of the model for the input view func TestInputView(t *testing.T) { - focusInput := "10m" - breakInput := "5m" - intervalInput := "1" + m := newModel(false, "#ffdd57", "#57ddff", 0, 0, 0, []string{}, []string{}, []string{}) - m := initialModel(false, "#ffdd57", "#57ddff", &focusInput, &breakInput, &intervalInput) - m.View() - - assertEqual(t, m.currentScreen, INPUT_SCREEN, "Expected currentScreen to be inputScreen") + var updatedModel tea.Model var resultCmd tea.Cmd - m, resultCmd = sendKeys(m, tea.KeyTab, tea.KeyTab, tea.KeyTab, tea.KeyEnter) + + updatedModel, _ = m.Update(m.Init()) + m = updatedModel.(model) + + // Verify we're on the input screen + assertEqual(t, m.currentScreen, INPUT_SCREEN, "Expected currentScreen to be inputScreen") + assertFunc(t, strings.Contains, m.View(), "Break time", "Expected view to contain 'enter next'") + + // Fill the form + m, resultCmd = sendKeys(m, "10m", tea.KeyEnter, "10m", tea.KeyEnter, "2", tea.KeyEnter) assertNotEqual(t, resultCmd, nil, "Expected resultCmd to be not nil") - /* - * assertEqual(t, m.form.State, huh.StateCompleted, fmt.Sprintf("Expected form state to be completed: %s", m.form.View())) - * assertEqual(t, m.currentScreen, TIMER_SCREEN, "Expected currentScreen to be timerScreen") - * assertEqual(t, m.remaining.Round(time.Second), 10*time.Minute, "Expected remaining to be 10 minutes") - */ + // Apply final next result + updatedModel, resultCmd = m.Update(resultCmd()) + m = updatedModel.(model) + + // Make sure the next command is to submit the form + assertNotEqual(t, resultCmd, nil, "Expected resultCmd to be not nil") + assertEqual( + t, + reflect.ValueOf(resultCmd).Pointer(), + reflect.ValueOf(formSubmit).Pointer(), + fmt.Sprintf("Expected resultCmd to be formSubmit, found %v", runtime.FuncForPC(reflect.ValueOf(resultCmd).Pointer()).Name()), + ) + + // Apply submit form command + updatedModel, resultCmd = m.Update(resultCmd()) + m = updatedModel.(model) assertEqual(t, m.err, nil, "Expected no error") + assertNotEqual(t, resultCmd, nil, "Expected resultCmd to be not nil") + assertEqual( + t, + reflect.ValueOf(resultCmd).Pointer(), + reflect.ValueOf(startTimer).Pointer(), + fmt.Sprintf("Expected resultCmd to be startTimer, found %v", runtime.FuncForPC(reflect.ValueOf(resultCmd).Pointer()).Name()), + ) } func TestTimerView(t *testing.T) { - m := initialModel(false, "#ffdd57", "#57ddff", nil, nil, nil) - m.View() + m := newModel(false, "#ffdd57", "#57ddff", 10*time.Minute, 10*time.Minute, 2, []string{}, []string{}, []string{}) - m.focusTime = 10 * time.Minute - m.breakTime = 10 * time.Minute - m.intervals = 2 - m.state = "Focus" - m.currentScreen = TIMER_SCREEN - m.startTime = time.Now() + var updatedModel tea.Model + + // Init model with timer values + updatedModel, _ = m.Update(m.Init()) + m = updatedModel.(model) + + // Start timer (batch result from above doesn't apply here) + updatedModel, _ = m.Update(startTimer()) + m = updatedModel.(model) // Test timer view - m.View() + assertEqual(t, m.currentScreen, TIMER_SCREEN, "Expected currentScreen to be timerScreen") + assertFunc(t, strings.Contains, m.View(), "Focus", "Expected view to contain 'Focus'") + assertEqual(t, m.state, "Focus", "Expected state to be 'Focus'") oneSec := timeMsg(time.Now().Add(1 * time.Second)) - updatedModel, _ := m.Update(oneSec) + updatedModel, _ = m.Update(oneSec) m = updatedModel.(model) assertEqual(t, m.state, "Focus", "Expected state to be 'Focus'")