package main import ( "fmt" "os" "os/exec" "os/signal" "strconv" "strings" "time" "github.com/charmbracelet/bubbles/progress" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/urfave/cli/v2" ) type screen int var version = "dev" const ( inputScreen screen = iota timerScreen ) type model struct { focusIndex int inputs []textinput.Model progressBar progress.Model state string intervalNum int timer *time.Timer remaining time.Duration totalTime time.Duration currentScreen screen intervals int breakTime time.Duration focusTime time.Duration onFocusStart []string onFocusEnd []string onIntervalEnd []string isFocus bool fullscreen bool width int height int err error } func initialModel(fullscreen bool, colorLeft string, colorRight string) model { numInputs := 3 inputs := make([]textinput.Model, numInputs) // Set up text input models for interval length, break length, and total intervals for i := range inputs { inputs[i] = textinput.New() inputs[i].CharLimit = 10 // Increase char limit to allow duration strings if i == 0 { inputs[i].Focus() // Start focus on first 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 } inputs[0].Placeholder = "Interval length (minutes or duration)" inputs[0].Validate = validateDuration inputs[1].Placeholder = "Break length (minutes or duration)" inputs[1].Validate = validateDuration inputs[2].Placeholder = "Number of intervals" inputs[2].Validate = validateInt if colorLeft == "" || colorRight == "" { panic("Color values must be provided") } return model{ inputs: inputs, progressBar: progress.New(progress.WithScaledGradient(colorLeft, colorRight)), state: "stopped", intervalNum: 1, remaining: 0, totalTime: 0, currentScreen: inputScreen, // Start on input screen isFocus: true, fullscreen: fullscreen, } } 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 fmt.Println("\nExiting...") os.Exit(0) }() return tea.Batch(tea.WindowSize(), textinput.Blink) } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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 == timerScreen { return m, tea.Quit } case "tab", "ctrl+n", "j", "down": // Move to the next input field m.focusIndex = (m.focusIndex + 1) % len(m.inputs) m.updateFocus() return m, nil case "shift+tab", "ctrl+p", "k", "up": // Move to the previous input field m.focusIndex = (m.focusIndex - 1 + len(m.inputs)) % len(m.inputs) m.updateFocus() return m, nil case "enter": if m.currentScreen == inputScreen { // Handle inputs and move to the timer screen if m.focusIndex == len(m.inputs)-1 { focusTime, err := parseDuration(m.inputs[0].Value()) if err != nil { m.err = fmt.Errorf("error parsing focus time duration: %w", err) return m, nil } breakTime, err := parseDuration(m.inputs[1].Value()) if err != nil { m.err = fmt.Errorf("error parsing break time duration: %w", err) return m, nil } m.intervals, err = strconv.Atoi(m.inputs[2].Value()) if err != nil { m.err = fmt.Errorf("error parsing interval: %w", err) return m, nil } m.focusTime = focusTime m.breakTime = breakTime m.remaining = focusTime m.totalTime = focusTime m.state = "Focus" m.timer = time.NewTimer(time.Second) m.currentScreen = timerScreen // Run onFocusStart commands m.runCommands(m.onFocusStart) return m, tick() } else { // Move to next input field m.focusIndex++ m.updateFocus() } } } case timeMsg: // Handle timer update for each second // TODO: Use absolute times to tick down remaining time rather than incrementing seconds m.remaining -= time.Second if m.remaining < 0 { if m.isFocus { // Focus period ends, switch to break m.isFocus = false m.remaining = m.remaining + m.breakTime m.totalTime = m.breakTime m.state = "Break" // Run onFocusEnd commands m.runCommands(m.onFocusEnd) } else { // Break ends, switch back to focus or next interval m.isFocus = true m.intervalNum++ m.runCommands(m.onIntervalEnd) if m.intervalNum > m.intervals { // All intervals completed return m, tea.Quit } m.remaining = m.remaining + m.focusTime m.totalTime = m.focusTime m.state = "Focus" // Run onFocusStart commands m.runCommands(m.onFocusStart) } return m, tick() } return m, tick() case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height } // Update text inputs cmds := make([]tea.Cmd, len(m.inputs)) for i := range m.inputs { m.inputs[i], cmds[i] = m.inputs[i].Update(msg) } 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 inputScreen: s.WriteString(m.inputScreenView()) case timerScreen: 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") for i := range m.inputs { builder.WriteString(m.inputs[i].View()) if m.inputs[i].Value() != "" && m.inputs[i].Err != nil { builder.WriteString(fmt.Sprintf("Error: %v", m.inputs[i].Err)) } builder.WriteString("\n") } builder.WriteString("\nUse TAB to navigate, ENTER to start.") 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.String()) timerView := fmt.Sprintf("%s\n%s\n%s\n\n%s", 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 update input focus func (m *model) updateFocus() { for i := range m.inputs { if i == m.focusIndex { m.inputs[i].Focus() } else { m.inputs[i].Blur() } } } // Helper to run shell commands func (m *model) runCommands(commands []string) { for _, cmdStr := range commands { cmd := exec.Command("sh", "-c", cmdStr) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { fmt.Printf("Error running command: %v\n", 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 } fmt.Printf("color-left: %s\n", c.String("color-left")) fmt.Printf("color-right: %s\n", c.String("color-right")) m := initialModel(c.Bool("fullscreen"), c.String("color-left"), c.String("color-right")) // Collect command flags m.onFocusStart = c.StringSlice("on-focus-start") m.onFocusEnd = c.StringSlice("on-focus-end") m.onIntervalEnd = c.StringSlice("on-interval-end") // Set focus, break, and interval values if provided if c.Duration("focus").String() != "0s" { m.inputs[0].SetValue(c.String("focus")) } if c.Duration("break").String() != "0s" { m.inputs[1].SetValue(c.String("break")) } if c.Int("intervals") != 0 { m.inputs[2].SetValue(c.String("intervals")) } // 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) } }