Totally new with a TUI and no json files
This commit is contained in:
parent
8035fbbf17
commit
64ea47f42b
2
.gitignore
vendored
2
.gitignore
vendored
@ -25,3 +25,5 @@ _testmain.go
|
|||||||
*.prof
|
*.prof
|
||||||
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
gomodoro
|
||||||
|
coverage.out
|
||||||
|
50
Makefile
Normal file
50
Makefile
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
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
|
||||||
|
TARGETS = $(addprefix dist/,$(TARGET_ALIAS))
|
||||||
|
#
|
||||||
|
# Default make target will run tests
|
||||||
|
.DEFAULT_GOAL = test
|
||||||
|
|
||||||
|
# Build all static Minitor binaries
|
||||||
|
.PHONY: all
|
||||||
|
all: $(TARGETS)
|
||||||
|
|
||||||
|
# Build app for the current machine
|
||||||
|
$(APP_NAME): $(GOFILES)
|
||||||
|
@echo Version: $(VERSION)
|
||||||
|
go build -ldflags '-X "main.version=${VERSION}"' -o $(APP_NAME)
|
||||||
|
|
||||||
|
.PHONY: build
|
||||||
|
build: $(APP_NAME)
|
||||||
|
|
||||||
|
# Run app for the current machine
|
||||||
|
.PHONY: run
|
||||||
|
run: $(APP_NAME)
|
||||||
|
./$(APP_NAME)
|
||||||
|
|
||||||
|
# Run all tests
|
||||||
|
.PHONY: test
|
||||||
|
test:
|
||||||
|
go test -coverprofile=coverage.out
|
||||||
|
go tool cover -func=coverage.out
|
||||||
|
@go tool cover -func=coverage.out | awk -v target=80.0% \
|
||||||
|
'/^total:/ { print "Total coverage: " $$3 " Minimum coverage: " target; if ($$3+0.0 >= target+0.0) print "ok"; else { print "fail"; exit 1; } }'
|
||||||
|
|
||||||
|
# Installs pre-commit hooks
|
||||||
|
.PHONY: install-hooks
|
||||||
|
install-hooks:
|
||||||
|
pre-commit install --install-hooks
|
||||||
|
|
||||||
|
# Runs pre-commit checks on files
|
||||||
|
.PHONY: check
|
||||||
|
check:
|
||||||
|
pre-commit run --all-files
|
||||||
|
|
||||||
|
.PHONY: clean
|
||||||
|
clean:
|
||||||
|
rm -f ./$(APP_NAME)
|
||||||
|
rm -f ./coverage.out
|
||||||
|
rm -fr ./dist
|
33
go.mod
Normal file
33
go.mod
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
module git.iamthefij.com/iamthefij/gomodoro
|
||||||
|
|
||||||
|
go 1.21.4
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/charmbracelet/bubbles v0.20.0
|
||||||
|
github.com/charmbracelet/bubbletea v1.1.1
|
||||||
|
github.com/charmbracelet/harmonica v0.2.0
|
||||||
|
github.com/charmbracelet/lipgloss v0.13.0
|
||||||
|
github.com/urfave/cli/v2 v2.27.5
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/atotto/clipboard v0.1.4 // indirect
|
||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
|
github.com/charmbracelet/x/ansi v0.2.3 // indirect
|
||||||
|
github.com/charmbracelet/x/term v0.2.0 // indirect
|
||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.5 // 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/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/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
|
||||||
|
)
|
51
go.sum
Normal file
51
go.sum
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
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/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/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/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/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=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||||
|
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/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/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=
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
|
github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w=
|
||||||
|
github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ=
|
||||||
|
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
|
||||||
|
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
|
||||||
|
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=
|
452
main.go
452
main.go
@ -1,144 +1,364 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"flag"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
|
||||||
"net"
|
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"path"
|
"strconv"
|
||||||
"syscall"
|
|
||||||
"time"
|
"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 (
|
const (
|
||||||
ConfigDir = ".config/gomodoro"
|
inputScreen screen = iota
|
||||||
LogFileName = "gomodoro.json"
|
timerScreen
|
||||||
)
|
)
|
||||||
|
|
||||||
// Gomodoro holds the config for the current timer
|
type model struct {
|
||||||
type TimerConfig struct {
|
focusIndex int
|
||||||
StartTime time.Time
|
inputs []textinput.Model
|
||||||
Duration time.Duration
|
progressBar progress.Model
|
||||||
Interval time.Duration
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// PomoStatus Represents the status of the Gomodor timer at a given moment
|
func initialModel(fullscreen bool, colorLeft string, colorRight string) model {
|
||||||
type PomoStatus struct {
|
inputs := make([]textinput.Model, 3)
|
||||||
Status string
|
|
||||||
StartTime int64
|
|
||||||
DurationSeconds float64
|
|
||||||
RemainingSeconds float64
|
|
||||||
Done bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// timeRemaining returns the remaining duration rounded to the nearest interval
|
// Set up text input models for interval length, break length, and total intervals
|
||||||
func timeRemaining(start time.Time, duration time.Duration, interval time.Duration) time.Duration {
|
for i := range inputs {
|
||||||
return (duration - time.Since(start)).Round(interval)
|
inputs[i] = textinput.New()
|
||||||
}
|
inputs[i].CharLimit = 10 // Increase char limit to allow duration strings
|
||||||
|
if i == 0 {
|
||||||
// fmtJSON returns a JSON string for a given interface and safely returns an err
|
inputs[i].Focus() // Start focus on first input
|
||||||
func fmtJSON(obj interface{}) (string, error) {
|
|
||||||
bstring, err := json.Marshal(obj)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return string(bstring[:]), err
|
|
||||||
}
|
|
||||||
|
|
||||||
// logRemaining prints a JSON formatted string logging the time remaining in the timer
|
|
||||||
func logRemaining(logFile *os.File, timerConfig TimerConfig) error {
|
|
||||||
timeRemaining := timeRemaining(timerConfig.StartTime, timerConfig.Duration, timerConfig.Interval)
|
|
||||||
err := logTextStatus(logFile, timerConfig, timeRemaining.String(), false)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// logTextStatus logs the status of a timer with a given string
|
|
||||||
func logTextStatus(logFile *os.File, timerConfig TimerConfig, status string, done bool) error {
|
|
||||||
// TODO: Find a better way than calculating this twice
|
|
||||||
timeRemaining := timeRemaining(timerConfig.StartTime, timerConfig.Duration, timerConfig.Interval)
|
|
||||||
jstring, err := fmtJSON(&PomoStatus{
|
|
||||||
Status: status,
|
|
||||||
StartTime: timerConfig.StartTime.Unix(),
|
|
||||||
DurationSeconds: timerConfig.Duration.Seconds(),
|
|
||||||
RemainingSeconds: timeRemaining.Seconds(),
|
|
||||||
Done: done,
|
|
||||||
})
|
|
||||||
if err == nil {
|
|
||||||
if logFile != nil {
|
|
||||||
logFile.WriteString(jstring + "\n")
|
|
||||||
}
|
}
|
||||||
fmt.Printf("%s / %s\n", status, timerConfig.Duration.String())
|
|
||||||
|
|
||||||
}
|
}
|
||||||
return err
|
|
||||||
|
inputs[0].Placeholder = "Interval length (minutes or duration)"
|
||||||
|
inputs[1].Placeholder = "Break length (minutes or duration)"
|
||||||
|
inputs[2].Placeholder = "Number of intervals"
|
||||||
|
|
||||||
|
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 tryLock() (net.Listener, error) {
|
func (m model) Init() tea.Cmd {
|
||||||
return net.Listen("unix", "/tmp/gomodoro")
|
// 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 textinput.Blink
|
||||||
}
|
}
|
||||||
|
|
||||||
func openLogFile() (*os.File, error) {
|
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
dir := path.Join(os.Getenv("HOME"), ConfigDir)
|
switch msg := msg.(type) {
|
||||||
os.MkdirAll(dir, os.ModePerm)
|
case tea.KeyMsg:
|
||||||
logFile, err := os.OpenFile(path.Join(dir, LogFileName), os.O_APPEND|os.O_RDWR|os.O_CREATE, os.ModePerm)
|
switch msg.String() {
|
||||||
return logFile, err
|
case "ctrl+c":
|
||||||
|
// Gracefully exit
|
||||||
|
return m, tea.Quit
|
||||||
|
|
||||||
|
case "tab":
|
||||||
|
// Move to the next input field
|
||||||
|
m.focusIndex = (m.focusIndex + 1) % len(m.inputs)
|
||||||
|
m.updateFocus()
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case "shift+tab":
|
||||||
|
// 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 {
|
||||||
|
focusTime, _ = time.ParseDuration(m.inputs[0].Value() + "m")
|
||||||
|
}
|
||||||
|
|
||||||
|
breakTime, err := parseDuration(m.inputs[1].Value())
|
||||||
|
if err != nil {
|
||||||
|
breakTime, _ = time.ParseDuration(m.inputs[1].Value() + "m")
|
||||||
|
}
|
||||||
|
|
||||||
|
m.intervals = atoi(m.inputs[2].Value())
|
||||||
|
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
|
||||||
|
m.remaining -= time.Second
|
||||||
|
if m.remaining < 0 {
|
||||||
|
m.runCommands(m.onIntervalEnd)
|
||||||
|
|
||||||
|
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++
|
||||||
|
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:
|
||||||
|
if m.fullscreen {
|
||||||
|
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 {
|
||||||
|
switch m.currentScreen {
|
||||||
|
case inputScreen:
|
||||||
|
return m.inputScreenView()
|
||||||
|
case timerScreen:
|
||||||
|
return m.timerScreenView()
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// View for input screen
|
||||||
|
func (m model) inputScreenView() string {
|
||||||
|
var builder string
|
||||||
|
builder = "Enter your Pomodoro settings:\n\n"
|
||||||
|
for i := range m.inputs {
|
||||||
|
builder += m.inputs[i].View() + "\n"
|
||||||
|
}
|
||||||
|
builder += "\nUse TAB to navigate, ENTER to start."
|
||||||
|
return builder
|
||||||
|
}
|
||||||
|
|
||||||
|
// View for timer screen with optional fullscreen centering
|
||||||
|
func (m model) timerScreenView() string {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
return time.ParseDuration(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to convert string to int
|
||||||
|
func atoi(s string) int {
|
||||||
|
n, _ := strconv.Atoi(s)
|
||||||
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
var duration time.Duration
|
// Set up CLI app
|
||||||
flag.DurationVar(&duration, "duration", 25*time.Minute, "Gomodoro duration")
|
app := &cli.App{
|
||||||
var interval time.Duration
|
Name: "Gomodoro",
|
||||||
flag.DurationVar(&interval, "interval", 1*time.Second, "Gomodoro tick interval")
|
Usage: "A Pomodoro timer with customizable shell commands",
|
||||||
/* Haven't found a good way to tail a file in golang yet
|
Flags: []cli.Flag{
|
||||||
* var status bool
|
&cli.StringSliceFlag{
|
||||||
* flag.BoolVar(&status, "status", false, "Only display current Gomodoro status")
|
Name: "on-focus-start",
|
||||||
*/
|
Usage: "Command(s) to run when focus starts",
|
||||||
flag.Parse()
|
},
|
||||||
|
&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 (default prompt for input)",
|
||||||
|
},
|
||||||
|
&cli.DurationFlag{
|
||||||
|
Name: "break",
|
||||||
|
Usage: "Break time duration (default prompt for input)",
|
||||||
|
},
|
||||||
|
&cli.IntFlag{
|
||||||
|
Name: "intervals",
|
||||||
|
Usage: "Number of intervals (default prompt for input)",
|
||||||
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "color-left",
|
||||||
|
Usage: "Left color for progress bar",
|
||||||
|
DefaultText: "#ffdd57",
|
||||||
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "color-right",
|
||||||
|
Usage: "Right color for progress bar",
|
||||||
|
DefaultText: "#57ddff",
|
||||||
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "version",
|
||||||
|
Usage: "Show version",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Action: func(c *cli.Context) error {
|
||||||
|
if c.Bool("version") {
|
||||||
|
fmt.Println(version)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure obtain lock
|
m := initialModel(c.Bool("fullscreen"), c.String("color-left"), c.String("color-right"))
|
||||||
lock, err := tryLock()
|
|
||||||
|
// 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
|
||||||
|
p := tea.NewProgram(m, tea.WithAltScreen())
|
||||||
|
_, err := p.Run()
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := app.Run(os.Args)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// TODO: More helpful message
|
fmt.Println("Error:", err)
|
||||||
log.Fatal(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer lock.Close()
|
|
||||||
|
|
||||||
// Open file for logging
|
|
||||||
logFile, err := openLogFile()
|
|
||||||
if err != nil {
|
|
||||||
lock.Close()
|
|
||||||
log.Fatal(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer logFile.Close()
|
|
||||||
|
|
||||||
// Start timer
|
|
||||||
start := time.Now()
|
|
||||||
timerConfig := TimerConfig{
|
|
||||||
StartTime: start,
|
|
||||||
Duration: duration,
|
|
||||||
Interval: interval,
|
|
||||||
}
|
|
||||||
tick := time.Tick(interval)
|
|
||||||
end := time.After(duration)
|
|
||||||
stop := make(chan os.Signal, 1)
|
|
||||||
signal.Notify(stop, os.Interrupt, os.Kill, syscall.SIGINT, syscall.SIGTERM)
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-tick:
|
|
||||||
logRemaining(logFile, timerConfig)
|
|
||||||
case <-end:
|
|
||||||
logTextStatus(logFile, timerConfig, "Complete", true)
|
|
||||||
return
|
|
||||||
case <-stop:
|
|
||||||
logTextStatus(logFile, timerConfig, "Interrupted", true)
|
|
||||||
return
|
|
||||||
default:
|
|
||||||
time.Sleep(50 * time.Millisecond)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user