Compare commits

...

10 Commits

Author SHA1 Message Date
edaa60c13a Fix CPU use bug
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
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.
2024-10-24 15:54:30 -07:00
631dc539f2 Move some of the test functions around for readability
All checks were successful
continuous-integration/drone/push Build is passing
2024-10-24 12:24:37 -07:00
5dc0d173f4 Refactor tests to handle batch message returns
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/tag Build is passing
2024-10-24 12:17:35 -07:00
c0f979a48a Add title setting
Some checks reported errors
continuous-integration/drone/push Build is failing
continuous-integration/drone/tag Build was killed
2024-10-24 10:08:15 -07:00
6d5cd3bad5 Update tortoise to include better error handling
All checks were successful
continuous-integration/drone/push Build is passing
2024-10-23 14:01:32 -07:00
c86f080bc7 Refactor and improve test coverage 2024-10-23 14:00:45 -07:00
7729646093 Rename go-shell-runner to tortoise
All checks were successful
continuous-integration/drone/push Build is passing
2024-10-21 17:17:51 -07:00
195004a4f4 Add example usage to Readme
All checks were successful
continuous-integration/drone/push Build is passing
2024-10-21 16:24:09 -07:00
0d2589043f Use huh for form input
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2024-10-21 14:48:00 -07:00
10c345bc96 Use shell runner 2024-10-21 13:35:53 -07:00
7 changed files with 543 additions and 252 deletions

1
.gitignore vendored
View File

@ -27,3 +27,4 @@ _testmain.go
.DS_Store
gomodoro
coverage.out
dist/

View File

@ -2,7 +2,7 @@ VERSION ?= $(shell git describe --tags --dirty)
GOFILES = *.go go.mod go.sum
APP_NAME = gomodoro
# Multi-arch targets are generated from this
TARGET_ALIAS = $(APP_NAME)-linux-amd64 $(APP_NAME)-linux-arm $(APP_NAME)-linux-arm64 $(APP_NAME)-darwin-amd64
TARGET_ALIAS = $(APP_NAME)-linux-amd64 $(APP_NAME)-linux-arm $(APP_NAME)-linux-arm64 $(APP_NAME)-darwin-amd64 $(APP_NAME)-darwin-arm64
TARGETS = $(addprefix dist/,$(TARGET_ALIAS))
#
# Default make target will run tests

View File

@ -87,6 +87,30 @@ Set custom colors for the progress bar:
./gomodoro --color-left="#ff0000" --color-right="#00ff00"
```
## Some Pro Tips!
- Use the `--on-focus-start` and `--on-focus-end` options to run commands that help you get into the zone and wind down after a focus period or to control your environment (e.g., turning off notifications).
### My example usage
I used Shortcuts on my mac to control macOS Focus mode. I named them "Turn On Focus" and "Turn Off Focus". This allows my focus start and end commands to control my notifications for my computer and phone (since I have them synced).
I also use [Slack Status CLI](https://git.iamthefij.com/iamthefij/slack-status-cli) to set my Slack status to a snooze mode with a tomato emoji when I'm in focus mode and turn it off after.
```sh
gomodoro \
--fullscreen \
--color-left '#ecdfb1' \
--color-right '#c6e889 ' \
--on-focus-start "shortcuts run 'Turn On Focus'" \
--on-focus-start "say 'Time to focus'" \
--on-focus-start "slack-status -snooze -duration 1h -emoji :tomato: Focus" \
--on-focus-end "shortcuts run 'Turn Off Focus'" \
--on-focus-end "say 'Break time you slacker'" \
--on-focus-end "slack-status" \
--on-interval-end "say 'Interval done! Phew!'"
```
## License
This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.

15
go.mod
View File

@ -3,9 +3,11 @@ module git.iamthefij.com/iamthefij/gomodoro
go 1.21.4
require (
git.iamthefij.com/iamthefij/slog v1.3.0
git.iamthefij.com/iamthefij/tortoise v1.0.1
github.com/charmbracelet/bubbles v0.20.0
github.com/charmbracelet/bubbletea v1.1.1
github.com/charmbracelet/harmonica v0.2.0
github.com/charmbracelet/huh v0.6.0
github.com/charmbracelet/lipgloss v0.13.0
github.com/urfave/cli/v2 v2.27.5
)
@ -13,21 +15,26 @@ require (
require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/catppuccin/go v0.2.0 // indirect
github.com/charmbracelet/harmonica v0.2.0 // indirect
github.com/charmbracelet/x/ansi v0.2.3 // indirect
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
github.com/charmbracelet/x/term v0.2.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.15.2 // indirect
github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
golang.org/x/sync v0.8.0 // indirect
golang.org/x/sys v0.24.0 // indirect
golang.org/x/text v0.3.8 // indirect
golang.org/x/sys v0.25.0 // indirect
golang.org/x/text v0.18.0 // indirect
)

28
go.sum
View File

@ -1,21 +1,35 @@
git.iamthefij.com/iamthefij/slog v1.3.0 h1:4Hu5PQvDrW5e3FrTS3q2iIXW0iPvhNY/9qJsqDR3K3I=
git.iamthefij.com/iamthefij/slog v1.3.0/go.mod h1:1RUj4hcCompZkAxXCRfUX786tb3cM/Zpkn97dGfUfbg=
git.iamthefij.com/iamthefij/tortoise v1.0.1 h1:dtUJuBhMZb9PlBR7bdcxLHLZDxhP2IPtT+YCR7tXdDk=
git.iamthefij.com/iamthefij/tortoise v1.0.1/go.mod h1:Y8Sn7jZaMbJw1AWoklp784o3pscfNiy1DkRy60Ne1KQ=
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA=
github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE=
github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU=
github.com/charmbracelet/bubbletea v1.1.1 h1:KJ2/DnmpfqFtDNVTvYZ6zpPFL9iRCRr0qqKOCvppbPY=
github.com/charmbracelet/bubbletea v1.1.1/go.mod h1:9Ogk0HrdbHolIKHdjfFpyXJmiCzGwy+FesYkZr7hYU4=
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
github.com/charmbracelet/huh v0.6.0 h1:mZM8VvZGuE0hoDXq6XLxRtgfWyTI3b2jZNKh0xWmax8=
github.com/charmbracelet/huh v0.6.0/go.mod h1:GGNKeWCeNzKpEOh/OJD8WBwTQjV3prFAtQPpLv+AVwU=
github.com/charmbracelet/lipgloss v0.13.0 h1:4X3PPeoWEDCMvzDvGmTajSyYPcZM4+y8sCA/SsA3cjw=
github.com/charmbracelet/lipgloss v0.13.0/go.mod h1:nw4zy0SBX/F/eAO1cWdcvy6qnkDUxr8Lw7dvFrAIbbY=
github.com/charmbracelet/x/ansi v0.2.3 h1:VfFN0NUpcjBRd4DnKfRaIRo53KRgey/nhOoEqosGDEY=
github.com/charmbracelet/x/ansi v0.2.3/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
github.com/charmbracelet/x/term v0.2.0 h1:cNB9Ot9q8I711MyZ7myUR5HFWL/lc3OpU8jZ4hwm0x0=
github.com/charmbracelet/x/term v0.2.0/go.mod h1:GVxgxAbjUrmpvIINHIQnJJKpMlHiZ4cktEQCN6GWyF0=
github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc=
github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
@ -26,12 +40,14 @@ github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2J
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a h1:2MaM6YC3mGu54x+RKAA6JiFFHlHDY1UbkxqppT7wYOg=
github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a/go.mod h1:hxSnBBYLK21Vtq/PHd0S2FYCxBXzBua8ov5s1RobyRQ=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
@ -45,7 +61,7 @@ golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg=
golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=

394
main.go
View File

@ -1,17 +1,20 @@
package main
import (
"errors"
"fmt"
"os"
"os/exec"
"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"
)
@ -21,47 +24,58 @@ type screen int
var version = "dev"
const (
inputScreen screen = iota
timerScreen
INPUT_SCREEN screen = iota
TIMER_SCREEN
FORM_FOCUS = "focus"
FORM_BREAK = "break"
FORM_INTERVALS = "intervals"
KILL_TIMEOUT = 10 * time.Second
)
type model struct {
focusIndex int
inputs []textinput.Model
// View state
form *huh.Form
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
// 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
}
func initialModel(fullscreen bool, colorLeft string, colorRight string) model {
numInputs := 3
inputs := make([]textinput.Model, numInputs)
// Message that signals to start the timer
type startTimerMsg struct{}
// 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
// Command that triggers start message
func startTimer() tea.Msg {
return startTimerMsg{}
}
if i == 0 {
inputs[i].Focus() // Start focus on first input
}
}
// 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
@ -75,45 +89,157 @@ func initialModel(fullscreen bool, colorLeft string, colorRight string) model {
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
// 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 == "" {
panic("Color values must be provided")
slog.Panicf("Color flags can't be empty")
}
return model{
inputs: inputs,
form: nil,
progressBar: progress.New(progress.WithScaledGradient(colorLeft, colorRight)),
state: "stopped",
intervalNum: 1,
remaining: 0,
totalTime: 0,
currentScreen: inputScreen, // Start on input screen
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)
}()
return tea.Batch(tea.WindowSize(), textinput.Blink)
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() {
@ -123,113 +249,108 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case "q":
// Quit the program if on the timer screen
if m.currentScreen == timerScreen {
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
}
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())
m.breakTime, err = parseDuration(m.form.GetString(FORM_BREAK))
if err != nil {
m.err = fmt.Errorf("error parsing break time duration: %w", err)
return m, nil
slog.Fatalf("Error parsing break time: %v", err)
return m, tea.Quit
}
m.intervals, err = strconv.Atoi(m.inputs[2].Value())
m.intervals, err = strconv.Atoi(m.form.GetString(FORM_INTERVALS))
if err != nil {
m.err = fmt.Errorf("error parsing interval: %w", err)
return m, nil
slog.Fatalf("Error parsing interval: %v", err)
return m, tea.Quit
}
m.focusTime = focusTime
m.breakTime = breakTime
m.remaining = focusTime
m.totalTime = focusTime
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.timer = time.NewTimer(time.Second)
m.currentScreen = timerScreen
m.startCommands(m.onFocusStart)
// Run onFocusStart commands
m.runCommands(m.onFocusStart)
return m, tick()
} else {
// Move to next input field
m.focusIndex++
m.updateFocus()
}
}
}
cmds = append(cmds, tea.SetWindowTitle(fmt.Sprintf("Gomodoro - %s", m.state)), tick())
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
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.remaining = m.remaining + m.breakTime
m.totalTime = m.breakTime
m.state = "Break"
m.startTime = time.Now()
// Run onFocusEnd commands
m.runCommands(m.onFocusEnd)
m.startCommands(m.onFocusEnd)
} else {
// Break ends, switch back to focus or next interval
m.isFocus = true
m.intervalNum++
m.runCommands(m.onIntervalEnd)
m.startCommands(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"
m.startTime = time.Now()
// Run onFocusStart commands
m.runCommands(m.onFocusStart)
m.startCommands(m.onFocusStart)
}
return m, tick()
cmds = append(cmds, tea.SetWindowTitle(fmt.Sprintf("Gomodoro - %s", m.state)))
}
return m, tick()
cmds = append(cmds, 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)
// 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...)
@ -249,9 +370,9 @@ func (m model) View() string {
var s strings.Builder
switch m.currentScreen {
case inputScreen:
case INPUT_SCREEN:
s.WriteString(m.inputScreenView())
case timerScreen:
case TIMER_SCREEN:
s.WriteString(m.timerScreenView())
}
@ -264,21 +385,15 @@ func (m model) View() 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")
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.")
builder.WriteString(m.form.View())
return builder.String()
}
@ -290,12 +405,12 @@ func (m model) timerScreenView() string {
m.progressBar.Width = m.width - progressBarPadding
}
progressView := m.progressBar.ViewAs(float64(m.totalTime-m.remaining) / float64(m.totalTime))
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())
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)
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)
@ -304,26 +419,11 @@ func (m model) timerScreenView() string {
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) {
func (m *model) startCommands(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)
if err := m.shellrunner.AddCommand(cmdStr, nil); err != nil {
m.err = fmt.Errorf("error adding command: %w", err)
}
}
}
@ -400,27 +500,17 @@ func main() {
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"))
}
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{}

View File

@ -1,9 +1,7 @@
package main
import (
"bytes"
"io"
"os"
"fmt"
"strings"
"testing"
"time"
@ -11,6 +9,22 @@ import (
tea "github.com/charmbracelet/bubbletea"
)
func notThis[T comparable](f compareFunc[T]) compareFunc[T] {
return func(a, b T) bool {
return !f(a, b)
}
}
type compareFunc[T comparable] func(T, T) bool
func assertFunc[T comparable](t *testing.T, compare compareFunc[T], actual, expected T, msg string) {
t.Helper()
if !compare(actual, expected) {
t.Errorf("%s: expected %v, got %v", msg, expected, actual)
}
}
// assertEqual checks if two values are equal and reports an error if they are not.
func assertEqual(t *testing.T, actual, expected interface{}, msg string) {
t.Helper()
@ -29,6 +43,171 @@ func assertNotEqual(t *testing.T, actual, expected interface{}, msg string) {
}
}
// keyMsgs converts a series of strings or KeyTypes into a slice of KeyMsgs
func keyMsgs(keys ...interface{}) []tea.KeyMsg {
keyMessages := []tea.KeyMsg{}
for _, key := range keys {
switch keyType := key.(type) {
case tea.KeyType:
keyMessages = append(keyMessages, tea.KeyMsg{Type: keyType})
case string:
for _, r := range key.(string) {
keyMessages = append(keyMessages, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
}
default:
panic(fmt.Sprintf("Unknown key type: %v", key))
}
}
return keyMessages
}
func sendKeys[T tea.Model](m T, keys ...interface{}) (T, []tea.Cmd) {
var updatedModel tea.Model
var cmd tea.Cmd
var cmds []tea.Cmd
for _, key := range keyMsgs(keys...) {
updatedModel, cmd = m.Update(key)
m = updatedModel.(T)
if cmd != nil {
m, cmds, _ = batchCmdUpdate(m, cmd)
}
}
return m, cmds
}
func batchCmdUpdate[T tea.Model](m T, cmd tea.Cmd) (T, []tea.Cmd, error) {
var updatedModel tea.Model
var resultCmd tea.Cmd
var cmds []tea.Cmd
resultMsg := cmd()
batchMsg, ok := resultMsg.(tea.BatchMsg)
if !ok {
updatedModel, resultCmd = m.Update(resultMsg)
m = updatedModel.(T)
cmds = append(cmds, resultCmd)
return m, cmds, fmt.Errorf("Expected a batch message, got %v", resultMsg)
}
// Apply batch messages
for _, cmd := range batchMsg {
updatedModel, resultCmd = m.Update(cmd())
m = updatedModel.(T)
cmds = append(cmds, resultCmd)
}
return m, cmds, nil
}
type mockModel struct {
messages []tea.Msg
}
func (m mockModel) Init() tea.Cmd {
return nil
}
func (m mockModel) View() string {
s := ""
for _, msg := range m.messages {
if keyMsg, ok := msg.(tea.KeyMsg); ok {
if keyMsg.Type == tea.KeyRunes {
s += string(keyMsg.Runes)
}
}
}
return s
}
func (m mockModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
m.messages = append(m.messages, msg)
if msg, ok := msg.(tea.KeyMsg); ok {
if msg.Type == tea.KeyEnter {
cmds = append(cmds, func() tea.Msg { return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'\n'}} })
}
}
return m, tea.Batch(cmds...)
}
func TestBatchCmdUpdate(t *testing.T) {
m := mockModel{}
cmds := []tea.Cmd{
func() tea.Msg { return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}} },
func() tea.Msg { return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'b'}} },
}
m, _, err := batchCmdUpdate(m, tea.Batch(cmds...))
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if len(m.messages) != 2 {
t.Fatalf("Expected 2 messages, got %v", len(m.messages))
}
if m.View() != "ab" {
t.Fatalf("Expected 'ab', got %s, %+v", m.View(), m)
}
m, _, err = batchCmdUpdate(m, func() tea.Msg { return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}} })
if err == nil {
t.Fatalf("Expected error, got nil")
}
if len(m.messages) != 3 {
t.Fatalf("Expected 3 messages, got %v", len(m.messages))
}
if m.View() != "abc" {
t.Fatalf("Expected 'abc', got %v", m.View())
}
}
func TestSendKeys(t *testing.T) {
m := mockModel{}
m, _ = sendKeys(m, "ab")
if len(m.messages) != 2 {
t.Fatalf("Expected 2 messages, got %v", len(m.messages))
}
if m.View() != "ab" {
t.Fatalf("Expected 'ab', got %s", m.View())
}
m, _ = sendKeys(m, "c", tea.KeyEnter)
// We expect 5 because enter key should create a new message for the newline
if len(m.messages) != 5 {
t.Fatalf("Expected 5 messages, got %v", len(m.messages))
}
if m.View() != "abc\n" {
t.Fatalf("Expected 'abc\n', got %s", m.View())
}
}
// TestParseDuration tests the parseDuration function
func TestParseDuration(t *testing.T) {
tests := []struct {
@ -54,103 +233,77 @@ func TestParseDuration(t *testing.T) {
// TestRunCommands tests the runCommands function
func TestRunCommands(t *testing.T) {
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
m := newModelBasic(false, "#ffdd57", "#57ddff")
m.onFocusStart = []string{"echo Focus Start"}
// Redirect stdout and stderr to the pipe
oldStdout := os.Stdout
oldStderr := os.Stderr
os.Stdout = w
os.Stderr = w
t.Cleanup(func() {
os.Stdout = oldStdout
os.Stderr = oldStderr
w.Close()
})
m := model{}
m.runCommands([]string{"echo Hello, World!"})
w.Close()
var buf bytes.Buffer
if _, err = io.Copy(&buf, r); err != nil {
t.Fatal(err)
}
output := buf.String()
assertEqual(t, strings.Contains(output, "Hello, World!"), true, "Expected output to contain 'Hello, World!'")
m.Init()
m.startCommands(m.onFocusStart)
}
// TestInputView tests the Update method of the model for the input view
func TestInputView(t *testing.T) {
m := initialModel(false, "#ffdd57", "#57ddff")
m.View()
m := newModel(false, "#ffdd57", "#57ddff", 0, 0, 0, []string{}, []string{}, []string{})
assertEqual(t, m.currentScreen, inputScreen, "Expected currentScreen to be inputScreen")
assertEqual(t, m.focusIndex, 0, "Expected focusIndex to be 0")
var err error
m.inputs[m.focusIndex].SetValue("10")
var msg tea.Msg
// Test tab key press
msg = tea.KeyMsg{Type: tea.KeyTab}
updatedModel, _ := m.Update(msg)
m = updatedModel.(model)
assertEqual(t, m.focusIndex, 1, "Expected focusIndex to be 1")
// Test shift+tab key press
msg = tea.KeyMsg{Type: tea.KeyShiftTab}
updatedModel, _ = m.Update(msg)
m = updatedModel.(model)
assertEqual(t, m.focusIndex, 0, "Expected focusIndex to be 0")
// Enter last value and test enter key press
for i := m.focusIndex; i < 2; i++ {
msg = tea.KeyMsg{Type: tea.KeyTab}
updatedModel, _ = m.Update(msg)
m = updatedModel.(model)
m.inputs[m.focusIndex].SetValue("10")
m, _, err = batchCmdUpdate(m, m.Init())
if err != nil {
t.Fatalf("Expected batch command after init: %v", err)
}
msg = tea.KeyMsg{Type: tea.KeyEnter}
updatedModel, resultCmd := m.Update(msg)
m = updatedModel.(model)
// Verify we're on the input screen
assertEqual(t, m.currentScreen, INPUT_SCREEN, "Expected currentScreen to be INPUT_SCREEN")
assertFunc(t, strings.Contains, m.View(), "Break time", "Expected view to contain 'Break time'")
assertEqual(t, m.err, nil, "Expected no error")
assertNotEqual(t, resultCmd, nil, "Expected resultCmd to be not nil")
assertEqual(t, m.currentScreen, timerScreen, "Expected currentScreen to be timerScreen")
assertEqual(t, m.remaining, 10*time.Minute, "Expected remaining to be 10 minutes")
// Fill the form
m, _ = sendKeys(m, "10m", tea.KeyEnter, "10m", tea.KeyEnter, "2", tea.KeyEnter)
// Pass on submit command, doesn't happen because we have multiple layers of batching at the end
m, _, _ = batchCmdUpdate(m, formSubmit)
assertEqual(t, m.focusTime, 10*time.Minute, "Expected focus time to be 10 minutes")
assertEqual(t, m.breakTime, 10*time.Minute, "Expected break time to be 10 minutes")
assertEqual(t, m.intervals, 2, "Expected rounds to be 2")
// Pass on start command, doesn't happen because we have multiple layers of batching at the end
m, _, _ = batchCmdUpdate(m, startTimer)
assertFunc(t, notThis(strings.Contains), m.View(), "Break time", "Expected view to NOT contain 'Break time'")
assertEqual(t, m.currentScreen, TIMER_SCREEN, "Expected currentScreen to be TIMER_SCREEN")
}
func TestTimerView(t *testing.T) {
m := newModel(false, "#ffdd57", "#57ddff", 10*time.Minute, 10*time.Minute, 2, []string{}, []string{}, []string{})
// Init model with timer values
m, _, err := batchCmdUpdate(m, m.Init())
if err != nil {
t.Fatalf("Expected batch command after init: %v", err)
}
// Start timer (batch result from above doesn't apply here)
m, _, _ = batchCmdUpdate(m, startTimer)
// Test timer view
m.View()
assertEqual(t, m.currentScreen, TIMER_SCREEN, "Expected currentScreen to be timerScreen")
assertFunc(t, strings.Contains, m.View(), "Focus", "Expected view to contain 'Focus'")
assertEqual(t, m.state, "Focus", "Expected state to be 'Focus'")
oneSec := timeMsg(time.Now().Add(1 * time.Second))
updatedModel, _ = m.Update(oneSec)
m = updatedModel.(model)
oneSecCmd := func() tea.Msg { return timeMsg(time.Now().Add(1 * time.Second)) }
m, _, _ = batchCmdUpdate(m, oneSecCmd)
assertEqual(t, m.state, "Focus", "Expected state to be 'Focus'")
assertEqual(t, m.remaining, 9*time.Minute+59*time.Second, "Expected remaining to be 9 minutes 59 seconds")
// Test switch to break time
m.remaining = 0
updatedModel, _ = m.Update(oneSec)
m = updatedModel.(model)
m.startTime = m.startTime.Add(-10 * time.Minute)
m, _, _ = batchCmdUpdate(m, oneSecCmd)
assertEqual(t, m.state, "Break", "Expected state to be 'Break'")
assertEqual(t, m.remaining, 9*time.Minute+59*time.Second, "Expected remaining to be 9 minutes 59 seconds")
// Switch back to focus time
m.remaining = 0
updatedModel, _ = m.Update(oneSec)
m = updatedModel.(model)
m.startTime = m.startTime.Add(-10 * time.Minute)
m, _, _ = batchCmdUpdate(m, oneSecCmd)
assertEqual(t, m.state, "Focus", "Expected state to be 'Focus'")
assertEqual(t, m.remaining, 9*time.Minute+59*time.Second, "Expected remaining to be 9 minutes 59 seconds")
t.Logf("Incorrect state %+v", m)
}