Compare commits
10 Commits
Author | SHA1 | Date | |
---|---|---|---|
edaa60c13a | |||
631dc539f2 | |||
5dc0d173f4 | |||
c0f979a48a | |||
6d5cd3bad5 | |||
c86f080bc7 | |||
7729646093 | |||
195004a4f4 | |||
0d2589043f | |||
10c345bc96 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -27,3 +27,4 @@ _testmain.go
|
||||
.DS_Store
|
||||
gomodoro
|
||||
coverage.out
|
||||
dist/
|
||||
|
2
Makefile
2
Makefile
@ -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
|
||||
|
24
README.md
24
README.md
@ -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
15
go.mod
@ -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
28
go.sum
@ -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=
|
||||
|
414
main.go
414
main.go
@ -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 "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 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
|
||||
// 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{}
|
||||
|
311
main_test.go
311
main_test.go
@ -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)
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user