Ian Fijolek
edaa60c13a
When I publish program level commands, they also come back around to my app causing it to eat tons of CPU. Now I'm only publishing this when the state changes.
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, 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)
|
|
}
|
|
}
|