gomodoro/main.go
2024-10-21 13:35:53 -07:00

466 lines
11 KiB
Go

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"
)
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
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) 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,
currentScreen: inputScreen, // 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(10 * time.Second)
fmt.Println("\nExiting...")
os.Exit(0)
}()
m.shellrunner.Start()
return tea.Batch(tea.WindowSize(), textinput.Blink)
}
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) {
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.state = "Focus"
m.currentScreen = timerScreen
m.startTime = time.Now()
// Run onFocusStart commands
m.startCommands(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 = 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
}
// 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)
}
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 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.Round(time.Second).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) 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 := 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)
}
}