534 lines
12 KiB
Go
534 lines
12 KiB
Go
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, 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, tick())
|
|
|
|
case tea.WindowSizeMsg:
|
|
m.width = msg.Width
|
|
m.height = msg.Height
|
|
}
|
|
|
|
cmds = append(cmds, tea.SetWindowTitle(fmt.Sprintf("Gomodoro - %s", m.state)))
|
|
|
|
// 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)
|
|
}
|
|
}
|