444 lines
10 KiB
Go
444 lines
10 KiB
Go
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)
|
|
}
|
|
}
|