gomodoro/main.go

534 lines
12 KiB
Go
Raw Permalink Normal View History

2018-01-19 01:42:41 +00:00
package main
import (
2024-10-21 20:35:53 +00:00
"errors"
2018-01-19 01:42:41 +00:00
"fmt"
"os"
"os/signal"
"strconv"
2024-10-18 19:21:55 +00:00
"strings"
2018-01-19 01:42:41 +00:00
"time"
2024-10-21 20:35:53 +00:00
"git.iamthefij.com/iamthefij/slog"
2024-10-22 00:17:51 +00:00
"git.iamthefij.com/iamthefij/tortoise"
"github.com/charmbracelet/bubbles/progress"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
2024-10-23 21:00:45 +00:00
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
"github.com/urfave/cli/v2"
2018-01-19 01:42:41 +00:00
)
type screen int
2024-10-21 21:48:00 +00:00
var version = "dev"
2018-01-19 01:42:41 +00:00
const (
2024-10-21 21:48:00 +00:00
INPUT_SCREEN screen = iota
TIMER_SCREEN
FORM_FOCUS = "focus"
FORM_BREAK = "break"
FORM_INTERVALS = "intervals"
KILL_TIMEOUT = 10 * time.Second
2018-01-19 01:42:41 +00:00
)
type model struct {
2024-10-23 21:00:45 +00:00
// View state
2024-10-21 21:48:00 +00:00
form *huh.Form
progressBar progress.Model
state string
intervalNum int
currentScreen screen
2024-10-23 21:00:45 +00:00
isFocus bool
fullscreen bool
width int
height int
// Timer state
remaining time.Duration
intervals int
breakTime time.Duration
focusTime time.Duration
2024-10-21 20:35:53 +00:00
startTime time.Time
onFocusStart []string
onFocusEnd []string
onIntervalEnd []string
2024-10-18 19:21:55 +00:00
err error
2024-10-22 00:17:51 +00:00
shellrunner *tortoise.ShellRunner
2018-01-19 01:42:41 +00:00
}
2024-10-23 21:00:45 +00:00
// 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 {
2024-10-21 21:48:00 +00:00
// Ceate validation functions for input
2024-10-18 19:21:55 +00:00
validateDuration := func(text string) error {
_, err := parseDuration(text)
return err
}
validateInt := func(text string) error {
2024-10-18 20:26:51 +00:00
if _, err := strconv.Atoi(text); err != nil {
return fmt.Errorf("invalid int input: %w", err)
}
return nil
2024-10-18 19:21:55 +00:00
}
2024-10-21 21:48:00 +00:00
// 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)
2024-10-18 21:23:09 +00:00
}
2024-10-23 21:00:45 +00:00
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{
2024-10-23 21:00:45 +00:00
form: nil,
progressBar: progress.New(progress.WithScaledGradient(colorLeft, colorRight)),
state: "stopped",
intervalNum: 1,
remaining: 0,
2024-10-21 21:48:00 +00:00
currentScreen: INPUT_SCREEN, // Start on input screen
isFocus: true,
fullscreen: fullscreen,
2024-10-22 00:17:51 +00:00
shellrunner: tortoise.NewShellRunner(),
}
2018-01-19 01:42:41 +00:00
}
2024-10-23 21:00:45 +00:00
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)
2024-10-18 19:21:55 +00:00
2024-10-23 21:00:45 +00:00
cmds := []tea.Cmd{tea.WindowSize(), textinput.Blink}
go func() {
<-c
2024-10-21 21:48:00 +00:00
_ = m.shellrunner.KillWithTimeout(KILL_TIMEOUT)
fmt.Println("\nExiting...")
os.Exit(0)
}()
2024-10-23 21:00:45 +00:00
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)
}
2024-10-21 20:35:53 +00:00
2024-10-23 21:00:45 +00:00
// 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...)
2018-01-19 01:42:41 +00:00
}
2024-10-21 21:48:00 +00:00
// totalTime returns the total time for the current period (focus or break)
2024-10-21 20:35:53 +00:00
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) {
2024-10-21 21:48:00 +00:00
cmds := []tea.Cmd{}
// Handle input screen
2024-10-23 21:00:45 +00:00
if m.currentScreen == INPUT_SCREEN && m.form != nil {
2024-10-21 21:48:00 +00:00
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() {
2024-10-18 21:23:09 +00:00
case "ctrl+c", "esc":
// Gracefully exit
return m, tea.Quit
2024-10-18 21:23:09 +00:00
case "q":
// Quit the program if on the timer screen
2024-10-21 21:48:00 +00:00
if m.currentScreen == TIMER_SCREEN {
2024-10-18 21:23:09 +00:00
return m, tea.Quit
}
}
2024-10-23 21:00:45 +00:00
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())
2024-10-23 21:00:45 +00:00
case timeMsg:
// Handle timer update for each second
2024-10-21 20:35:53 +00:00
m.remaining = m.totalTime() - time.Since(m.startTime)
2024-10-23 21:00:45 +00:00
// 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"
2024-10-21 20:35:53 +00:00
m.startTime = time.Now()
// Run onFocusEnd commands
2024-10-21 20:35:53 +00:00
m.startCommands(m.onFocusEnd)
} else {
// Break ends, switch back to focus or next interval
m.isFocus = true
m.intervalNum++
2024-10-21 20:35:53 +00:00
m.startCommands(m.onIntervalEnd)
2024-10-18 20:27:19 +00:00
if m.intervalNum > m.intervals {
// All intervals completed
return m, tea.Quit
}
2024-10-18 20:27:19 +00:00
m.state = "Focus"
2024-10-21 20:35:53 +00:00
m.startTime = time.Now()
// Run onFocusStart commands
2024-10-21 20:35:53 +00:00
m.startCommands(m.onFocusStart)
}
cmds = append(cmds, tea.SetWindowTitle(fmt.Sprintf("Gomodoro - %s", m.state)))
}
2024-10-18 20:27:33 +00:00
2024-10-24 17:08:15 +00:00
cmds = append(cmds, tick())
case tea.WindowSizeMsg:
2024-10-18 20:27:33 +00:00
m.width = msg.Width
m.height = msg.Height
}
2024-10-23 21:00:45 +00:00
// Get errors from shellrunner
2024-10-21 20:35:53 +00:00
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...)
2018-01-19 01:42:41 +00:00
}
// 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)
2018-01-19 01:42:41 +00:00
})
}
// View rendering for input and timer screens
func (m model) View() string {
2024-10-18 19:21:55 +00:00
var s strings.Builder
switch m.currentScreen {
2024-10-21 21:48:00 +00:00
case INPUT_SCREEN:
2024-10-18 19:21:55 +00:00
s.WriteString(m.inputScreenView())
2024-10-21 21:48:00 +00:00
case TIMER_SCREEN:
2024-10-18 19:21:55 +00:00
s.WriteString(m.timerScreenView())
}
2024-10-18 19:21:55 +00:00
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 {
2024-10-23 21:00:45 +00:00
if m.form == nil {
return "Loading..."
}
2024-10-18 19:21:55 +00:00
var builder strings.Builder
builder.WriteString("Enter your Pomodoro settings:\n\n")
2024-10-21 21:48:00 +00:00
builder.WriteString(m.form.View())
2024-10-18 19:21:55 +00:00
return builder.String()
}
// View for timer screen with optional fullscreen centering
func (m model) timerScreenView() string {
2024-10-18 20:27:33 +00:00
if m.fullscreen {
progressBarPadding := 5
m.progressBar.Width = m.width - progressBarPadding
}
2024-10-21 20:35:53 +00:00
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)
2024-10-21 20:35:53 +00:00
timeLeft := fmt.Sprintf("Time left: %s", m.remaining.Round(time.Second).String())
2024-10-21 21:48:00 +00:00
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)
}
2024-10-18 19:21:55 +00:00
return timerView
}
// Helper to run shell commands
2024-10-21 20:35:53 +00:00
func (m *model) startCommands(commands []string) {
for _, cmdStr := range commands {
2024-10-21 20:35:53 +00:00
if err := m.shellrunner.AddCommand(cmdStr, nil); err != nil {
m.err = fmt.Errorf("error adding command: %w", err)
}
2018-01-19 01:42:41 +00:00
}
}
// 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
}
2018-01-19 01:42:41 +00:00
2024-10-18 19:21:55 +00:00
d, err := time.ParseDuration(input)
if err != nil {
return d, fmt.Errorf("error parsing duration: %w", err)
}
return d, nil
2018-01-19 01:42:41 +00:00
}
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{
2024-10-18 19:21:55 +00:00
Name: "focus",
Usage: "Focus time duration",
DefaultText: "prompt for input",
},
&cli.DurationFlag{
2024-10-18 19:21:55 +00:00
Name: "break",
Usage: "Break time duration",
DefaultText: "prompt for input",
},
&cli.IntFlag{
2024-10-18 19:21:55 +00:00
Name: "intervals",
Usage: "Number of intervals",
DefaultText: "prompt for input",
},
&cli.StringFlag{
2024-10-18 19:21:55 +00:00
Name: "color-left",
Usage: "Left color for progress bar",
Value: "#ffdd57",
},
&cli.StringFlag{
2024-10-18 19:21:55 +00:00
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
}
2024-10-23 21:00:45 +00:00
m := newModel(
2024-10-21 21:48:00 +00:00
c.Bool("fullscreen"),
c.String("color-left"),
c.String("color-right"),
2024-10-23 21:00:45 +00:00
c.Duration("focus"),
c.Duration("break"),
c.Int("intervals"),
c.StringSlice("on-focus-start"),
c.StringSlice("on-focus-end"),
c.StringSlice("on-interval-end"),
2024-10-21 21:48:00 +00:00
)
// Start tea program
2024-10-18 19:21:55 +00:00
2024-10-18 20:27:33 +00:00
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
},
2018-01-19 01:42:41 +00:00
}
err := app.Run(os.Args)
2018-01-19 01:42:41 +00:00
if err != nil {
fmt.Println("Error:", err)
2018-01-19 01:42:41 +00:00
}
}