Files
MujicaUI/pomodoro_timer.go
T

213 lines
6.2 KiB
Go

package mujica
import (
"fmt"
"log/slog"
"math"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
// PomodoroPhase is the part of the cycle running.
type PomodoroPhase int
const (
PomodoroFocus PomodoroPhase = iota
PomodoroBreak
PomodoroLongBreak
)
// PomodoroState holds the cycle, sessions and running clock.
type PomodoroState struct {
phase PomodoroPhase
focused int
running bool
since time.Time
spent time.Duration
}
// Phase returns the current phase.
func (s *PomodoroState) Phase() PomodoroPhase { return s.phase }
// Sessions returns how many focus sessions finished.
func (s *PomodoroState) Sessions() int { return s.focused }
// Running reports whether the clock runs.
func (s *PomodoroState) Running() bool { return s.running }
func (s *PomodoroState) elapsed(now time.Time) time.Duration {
if s.running {
return s.spent + now.Sub(s.since)
}
return s.spent
}
// PomodoroTimerOptions configure a PomodoroTimer.
type PomodoroTimerOptions struct {
// Focus/Break/LongBreak default to 25/5/15 minutes.
Focus, Break, LongBreak time.Duration
// LongEvery counts sessions per long break; zero means four.
LongEvery int
// Now fixes the clock; zero uses the system clock.
Now time.Time
Disabled bool
Label string
}
// PomodoroResult is the timer and what changed this frame.
type PomodoroResult struct {
Element *ui.Element
changed bool
finished PomodoroPhase
done bool
}
// Changed reports a start, pause, skip or reset.
func (r PomodoroResult) Changed() bool { return r.changed }
// Finished returns the phase that ran out this frame.
func (r PomodoroResult) Finished() (PomodoroPhase, bool) { return r.finished, r.done }
// PomodoroTimer cycles focus and breaks with pause, skip and reset.
func PomodoroTimer(c *ui.Context, s *PomodoroState, opts PomodoroTimerOptions) PomodoroResult {
lengths := [3]time.Duration{opts.Focus, opts.Break, opts.LongBreak}
for i, def := range []time.Duration{25 * time.Minute, 5 * time.Minute, 15 * time.Minute} {
if lengths[i] < 0 || lengths[i]%time.Second != 0 {
panic(fmt.Sprintf("mujica: PomodoroTimer length %v is negative or finer than a second", lengths[i]))
}
if lengths[i] == 0 {
lengths[i] = def
}
}
every := opts.LongEvery
if every < 0 {
panic("mujica: PomodoroTimer LongEvery is negative")
}
if every == 0 {
every = 4
}
now := opts.Now
live := now.IsZero()
if live {
now = c.Now()
}
k := Tokens(c)
d := Density(c)
label := opts.Label
if label == "" {
label = msg(c, "pomodoro_timer.label")
}
r := PomodoroResult{}
next := func() {
if s.phase == PomodoroFocus {
s.focused++
s.phase = PomodoroBreak
if s.focused%every == 0 {
s.phase = PomodoroLongBreak
}
} else {
s.phase = PomodoroFocus
}
s.running, s.spent = false, 0
}
if s.running && s.elapsed(now) >= lengths[s.phase] {
r.finished, r.done = s.phase, true
c.Announce(fmt.Sprintf(msg(c, "pomodoro_timer.done"), msg(c, pomodoroKeys[s.phase])))
slog.Debug("mujica: pomodoro_timer finished", "phase", s.phase)
next()
}
col := ui.Column(c).Gap(d.Unit() * 3).AlignItems(ui.Center).Role(ui.RoleGroup).Label(label).Disabled(opts.Disabled)
r.Element = col
act := func(what string, fn func()) {
if col.IsDisabled() {
return
}
fn()
r.changed = true
slog.Debug("mujica: pomodoro_timer "+what, "phase", s.phase)
}
left := lengths[s.phase] - s.elapsed(now)
secs := int(math.Ceil(left.Seconds()))
frac := 1 - float32(secs)/float32(lengths[s.phase]/time.Second)
col.Children(func() {
phase := msg(c, pomodoroKeys[s.phase])
text := fmt.Sprintf("%02d:%02d", secs/60, secs%60)
ring := ui.Box(c).Size(160, 160).Center().Role(ui.RoleStatus).Label(fmt.Sprintf(msg(c, "pomodoro_timer.left"), phase, CountdownText(c, time.Duration(secs)*time.Second, CountdownWords)))
tone := k.AccentText
if s.phase != PomodoroFocus {
tone = k.Success
}
ring.Draw(func(p *ui.Painter, rr ui.Rect) {
cx, cy := internal.Snap(p, rr.X+rr.W/2), internal.Snap(p, rr.Y+rr.H/2)
p.FillPath(pomodoroSector(p, cx, cy, 70, 76, 2*math.Pi), k.Border)
if frac > 0 && !c.Preferences().ReduceMotion {
p.FillPath(pomodoroSector(p, cx, cy, 70, 76, 2*math.Pi*float64(frac)), tone)
}
})
ring.Children(func() {
ui.Column(c).AlignItems(ui.Center).Gap(2).Children(func() {
ui.Text(c, text).Font(theme.MonoFont).FontSize(FontSize(c, theme.H1Size)).TextColor(k.Text).FontFeatures("tnum")
ui.Text(c, phase).FontSize(FontSize(c, theme.CaptionSize)).TextColor(tone)
})
})
ui.Text(c, fmt.Sprintf(msg(c, "pomodoro_timer.sessions"), s.focused%every, every)).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
ui.Row(c).Gap(d.Unit() * 2).Children(func() {
key, icon, variant := "pomodoro_timer.start", "play", Primary
if s.running {
key, icon, variant = "pomodoro_timer.pause", "pause", Secondary
}
if Button(c, msg(c, key), ButtonOptions{Variant: variant, Icon: icons.Must(icon)}).Clicked() {
act("toggle", func() {
if s.running {
s.spent, s.running = s.elapsed(now), false
} else {
s.since, s.running = now, true
}
})
}
if Button(c, msg(c, "pomodoro_timer.skip"), ButtonOptions{Variant: Secondary}).Clicked() {
act("skip", next)
}
if Button(c, msg(c, "pomodoro_timer.reset"), ButtonOptions{Variant: Ghost, Icon: icons.Must("rotate-ccw")}).Clicked() {
act("reset", func() { *s = PomodoroState{} })
}
})
})
if s.running && live {
delay := left % time.Second
if delay == 0 {
delay = time.Second
}
c.After(delay)
}
return r
}
var pomodoroKeys = [...]string{"pomodoro_timer.focus", "pomodoro_timer.break", "pomodoro_timer.long_break"}
// pomodoroSector snaps every changing vertex to the cache grid.
func pomodoroSector(p *ui.Painter, cx, cy, inner, outer float32, end float64) *ui.Path {
var path ui.Path
n := max(2, int(math.Ceil(end/(math.Pi/48))))
for i := 0; i <= n; i++ {
x, y := internal.Polar(cx, cy, outer, end*float64(i)/float64(n))
x, y = internal.Snap(p, x), internal.Snap(p, y)
if i == 0 {
path.MoveTo(x, y)
} else {
path.LineTo(x, y)
}
}
for i := n; i >= 0; i-- {
x, y := internal.Polar(cx, cy, inner, end*float64(i)/float64(n))
path.LineTo(internal.Snap(p, x), internal.Snap(p, y))
}
path.Close()
return &path
}