package main import ( "errors" "fmt" "os" "os/signal" "strconv" "strings" "time" "git.iamthefij.com/iamthefij/slog" "git.iamthefij.com/iamthefij/tortoise" "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" ) type screen int var version = "dev" const ( INPUT_SCREEN screen = iota TIMER_SCREEN FORM_FOCUS = "focus" FORM_BREAK = "break" FORM_INTERVALS = "intervals" KILL_TIMEOUT = 10 * time.Second ) type model struct { // View state form *huh.Form progressBar progress.Model state string intervalNum int currentScreen screen isFocus bool fullscreen bool width int height int // Timer state remaining time.Duration intervals int breakTime time.Duration focusTime time.Duration startTime time.Time onFocusStart []string onFocusEnd []string onIntervalEnd []string err error shellrunner *tortoise.ShellRunner } // 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) return err } validateInt := func(text string) error { if _, err := strconv.Atoi(text); err != nil { return fmt.Errorf("invalid int input: %w", err) } return nil } // Create form fields focusTime := huh.NewInput(). Key(FORM_FOCUS). Title("Focus time"). Validate(validateDuration). Placeholder("20m"). Description("How long should a focus session be?") if defaultFocusTime != nil { focusTime = focusTime.Value(defaultFocusTime) } breakTime := huh.NewInput(). Key(FORM_BREAK). Title("Break time"). Validate(validateDuration). Placeholder("10m"). Description("How long should a break session be?") if defaultBreakTime != nil { breakTime = breakTime.Value(defaultBreakTime) } intervals := huh.NewInput(). Key(FORM_INTERVALS). Title("Intervals"). Validate(validateInt). Placeholder("2"). Description("How many intervals do you want to do?") if defaultIntervals != nil { 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: nil, progressBar: progress.New(progress.WithScaledGradient(colorLeft, colorRight)), state: "stopped", intervalNum: 1, remaining: 0, currentScreen: INPUT_SCREEN, // Start on input screen isFocus: true, fullscreen: fullscreen, shellrunner: tortoise.NewShellRunner(), } } 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 _ = m.shellrunner.KillWithTimeout(KILL_TIMEOUT) fmt.Println("\nExiting...") os.Exit(0) }() 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) } // 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) func (m model) totalTime() time.Duration { if m.isFocus { return m.focusTime } return m.breakTime } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds := []tea.Cmd{} // Handle 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) } // Handle any uncaptured input screen updates switch msg := msg.(type) { case tea.KeyMsg: switch msg.String() { case "ctrl+c", "esc": // Gracefully exit return m, tea.Quit case "q": // Quit the program if on the timer screen if m.currentScreen == TIMER_SCREEN { return m, tea.Quit } } 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) cmds = append(cmds, tea.SetWindowTitle(fmt.Sprintf("Gomodoro - %s", m.state)), 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 m.isFocus = false m.state = "Break" m.startTime = time.Now() // Run onFocusEnd commands m.startCommands(m.onFocusEnd) } else { // Break ends, switch back to focus or next interval m.isFocus = true m.intervalNum++ m.startCommands(m.onIntervalEnd) if m.intervalNum > m.intervals { // All intervals completed return m, tea.Quit } m.state = "Focus" m.startTime = time.Now() // Run onFocusStart commands m.startCommands(m.onFocusStart) } cmds = append(cmds, tea.SetWindowTitle(fmt.Sprintf("Gomodoro - %s", m.state))) } cmds = append(cmds, tick()) case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height } // Get errors from shellrunner for { result := m.shellrunner.GetResults() if result == nil { break } if result.ReturnCode != 0 { m.err = errors.Join(m.err, fmt.Errorf("error running command: %s", result.Command)) // TODO: Create an error view slog.Errorf("Error running command: %s", result.Command) slog.Errorf("Error: %s", result.Output) slog.Errorf("Error: %s", result.ErrOutput) } } return m, tea.Batch(cmds...) } // Tick every second type timeMsg time.Time func tick() tea.Cmd { return tea.Tick(time.Second, func(t time.Time) tea.Msg { return timeMsg(t) }) } // View rendering for input and timer screens func (m model) View() string { var s strings.Builder switch m.currentScreen { case INPUT_SCREEN: s.WriteString(m.inputScreenView()) case TIMER_SCREEN: s.WriteString(m.timerScreenView()) } if m.err != nil { s.WriteString(fmt.Sprintf("\n\nError: %v", m.err)) } return s.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") builder.WriteString(m.form.View()) return builder.String() } // View for timer screen with optional fullscreen centering func (m model) timerScreenView() string { if m.fullscreen { progressBarPadding := 5 m.progressBar.Width = m.width - progressBarPadding } progressView := m.progressBar.ViewAs(float64(m.totalTime()-m.remaining) / float64(m.totalTime())) status := lipgloss.NewStyle().Bold(true).Render(fmt.Sprintf("State: %s", m.state)) intervalInfo := fmt.Sprintf("Interval: %d / %d", m.intervalNum, m.intervals) timeLeft := fmt.Sprintf("Time left: %s", m.remaining.Round(time.Second).String()) timerView := fmt.Sprintf("%s\n%s\n%s\n\n%s\n\nPress q to quit", status, intervalInfo, timeLeft, progressView) if m.fullscreen { return lipgloss.NewStyle().Width(m.width).Height(m.height).Align(lipgloss.Center, lipgloss.Center).Render(timerView) } return timerView } // Helper to run shell commands func (m *model) startCommands(commands []string) { for _, cmdStr := range commands { if err := m.shellrunner.AddCommand(cmdStr, nil); err != nil { m.err = fmt.Errorf("error adding command: %w", err) } } } // Helper to parse duration or treat an integer as minutes func parseDuration(input string) (time.Duration, error) { if minutes, err := strconv.Atoi(input); err == nil { return time.Duration(minutes) * time.Minute, nil } d, err := time.ParseDuration(input) if err != nil { return d, fmt.Errorf("error parsing duration: %w", err) } return d, nil } func main() { // Set up CLI app app := &cli.App{ Name: "Gomodoro", Usage: "A Pomodoro timer with customizable shell commands", Flags: []cli.Flag{ &cli.StringSliceFlag{ Name: "on-focus-start", Usage: "Command(s) to run when focus starts", }, &cli.StringSliceFlag{ Name: "on-focus-end", Usage: "Command(s) to run when focus ends", }, &cli.StringSliceFlag{ Name: "on-interval-end", Usage: "Command(s) to run when any interval ends", }, &cli.BoolFlag{ Name: "fullscreen", Usage: "Enable fullscreen mode", }, &cli.DurationFlag{ Name: "focus", Usage: "Focus time duration", DefaultText: "prompt for input", }, &cli.DurationFlag{ Name: "break", Usage: "Break time duration", DefaultText: "prompt for input", }, &cli.IntFlag{ Name: "intervals", Usage: "Number of intervals", DefaultText: "prompt for input", }, &cli.StringFlag{ Name: "color-left", Usage: "Left color for progress bar", Value: "#ffdd57", }, &cli.StringFlag{ Name: "color-right", Usage: "Right color for progress bar", Value: "#57ddff", }, &cli.BoolFlag{ Name: "version", Usage: "Show version", }, }, Action: func(c *cli.Context) error { if c.Bool("version") { fmt.Println(version) return nil } m := newModel( c.Bool("fullscreen"), c.String("color-left"), c.String("color-right"), c.Duration("focus"), c.Duration("break"), c.Int("intervals"), c.StringSlice("on-focus-start"), c.StringSlice("on-focus-end"), c.StringSlice("on-interval-end"), ) // Start tea program options := []tea.ProgramOption{} if m.fullscreen { options = append(options, tea.WithAltScreen()) } p := tea.NewProgram(m, options...) if _, err := p.Run(); err != nil { return fmt.Errorf("error running program: %w", err) } return nil }, } err := app.Run(os.Args) if err != nil { fmt.Println("Error:", err) } }