package main import ( "errors" "fmt" "os" "os/signal" "strconv" "strings" "time" shellrunner "git.iamthefij.com/iamthefij/go-shell-runner" "git.iamthefij.com/iamthefij/slog" "github.com/charmbracelet/bubbles/progress" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/urfave/cli/v2" "github.com/charmbracelet/huh" ) 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 { form *huh.Form progressBar progress.Model state string intervalNum int remaining time.Duration currentScreen screen intervals int breakTime time.Duration focusTime time.Duration startTime time.Time onFocusStart []string onFocusEnd []string onIntervalEnd []string isFocus bool fullscreen bool width int height int err error shellrunner *shellrunner.ShellRunner } func initialModel(fullscreen bool, colorLeft string, colorRight string, defaultFocusTime, defaultBreakTime, defaultIntervals *string) model { // 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 } if colorLeft == "" || colorRight == "" { slog.Panicf("Color flags can't be empty") } // 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) } return model{ form: huh.NewForm(huh.NewGroup(focusTime, breakTime, intervals)).WithShowErrors(true), 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: shellrunner.NewShellRunner(), } } func (m model) Init() tea.Cmd { // Handle Ctrl+C for graceful exit c := make(chan os.Signal, 1) signal.Notify(c, os.Interrupt) go func() { <-c _ = m.shellrunner.KillWithTimeout(KILL_TIMEOUT) fmt.Println("\nExiting...") os.Exit(0) }() m.form.Init() m.shellrunner.Start() return tea.Batch(tea.WindowSize(), textinput.Blink) } // 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 { 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 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 timeMsg: // Handle timer update for each second m.remaining = m.totalTime() - time.Since(m.startTime) 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) } return m, tick() } return m, tick() case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height } 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 { 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 } // 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( c.Bool("fullscreen"), c.String("color-left"), c.String("color-right"), defaultFocusTime, defaultBreakTime, defaultIntervals, ) // 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{} 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) } }