From c5f4c055c4fc77042aa0fd0fe1d138bf027a6c85 Mon Sep 17 00:00:00 2001 From: wuyangfan <1102042793@qq.com> Date: Sun, 17 May 2026 21:28:32 +0800 Subject: [PATCH] fix(stopwatch): use wall clock for elapsed time and default interval Track elapsed duration from real time instead of tick count so the display stays accurate when the event loop delivers ticks late (e.g. Windows with sub-second intervals). Initialize Interval to one second as documented. Fixes charmbracelet/bubbles#862 Fixes charmbracelet/bubbles#237 Fixes charmbracelet/bubbletea#932 --- stopwatch/stopwatch.go | 32 ++++++++++++++++++----- stopwatch/stopwatch_test.go | 52 +++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 6 deletions(-) create mode 100644 stopwatch/stopwatch_test.go diff --git a/stopwatch/stopwatch.go b/stopwatch/stopwatch.go index 65facfc5..48a98f1e 100644 --- a/stopwatch/stopwatch.go +++ b/stopwatch/stopwatch.go @@ -54,6 +54,7 @@ type ResetMsg struct { // Model for the stopwatch component. type Model struct { d time.Duration + start time.Time id int tag int running bool @@ -65,7 +66,8 @@ type Model struct { // New creates a new stopwatch with 1s interval. func New(opts ...Option) Model { m := Model{ - id: nextID(), + id: nextID(), + Interval: time.Second, } for _, opt := range opts { @@ -86,9 +88,9 @@ func (m Model) Init() tea.Cmd { // Start starts the stopwatch. func (m Model) Start() tea.Cmd { - return tea.Sequence(func() tea.Msg { + return func() tea.Msg { return StartStopMsg{ID: m.id, running: true} - }, tick(m.id, m.tag, m.Interval)) + } } // Stop stops the stopwatch. @@ -125,12 +127,28 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { if msg.ID != m.id { return m, nil } - m.running = msg.running + if msg.running { + if !m.running { + m.start = time.Now() + } + m.running = true + return m, tick(m.id, m.tag, m.Interval) + } + if m.running { + m.d += time.Since(m.start) + m.start = time.Time{} + } + m.running = false case ResetMsg: if msg.ID != m.id { return m, nil } m.d = 0 + if m.running { + m.start = time.Now() + } else { + m.start = time.Time{} + } case TickMsg: if !m.running || msg.ID != m.id { break @@ -143,7 +161,6 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { return m, nil } - m.d += m.Interval m.tag++ return m, tick(m.id, m.tag, m.Interval) } @@ -153,12 +170,15 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { // Elapsed returns the time elapsed. func (m Model) Elapsed() time.Duration { + if m.running && !m.start.IsZero() { + return m.d + time.Since(m.start) + } return m.d } // View of the timer component. func (m Model) View() string { - return m.d.String() + return m.Elapsed().String() } func tick(id int, tag int, d time.Duration) tea.Cmd { diff --git a/stopwatch/stopwatch_test.go b/stopwatch/stopwatch_test.go new file mode 100644 index 00000000..edc44d07 --- /dev/null +++ b/stopwatch/stopwatch_test.go @@ -0,0 +1,52 @@ +package stopwatch + +import ( + "testing" + "time" +) + +func TestNewDefaultInterval(t *testing.T) { + t.Parallel() + m := New() + if m.Interval != time.Second { + t.Fatalf("Interval = %v, want %v", m.Interval, time.Second) + } +} + +func TestWithInterval(t *testing.T) { + t.Parallel() + m := New(WithInterval(5 * time.Second)) + if m.Interval != 5*time.Second { + t.Fatalf("Interval = %v, want %v", m.Interval, 5*time.Second) + } +} + +func TestElapsedUsesWallClock(t *testing.T) { + m := New(WithInterval(time.Hour)) + m, _ = m.Update(StartStopMsg{ID: m.id, running: true}) + + time.Sleep(50 * time.Millisecond) + + elapsed := m.Elapsed() + if elapsed < 40*time.Millisecond { + t.Fatalf("Elapsed() = %v, want at least 40ms", elapsed) + } + if elapsed > 200*time.Millisecond { + t.Fatalf("Elapsed() = %v, unexpectedly large", elapsed) + } +} + +func TestStopAccumulatesElapsed(t *testing.T) { + m := New(WithInterval(time.Hour)) + m, _ = m.Update(StartStopMsg{ID: m.id, running: true}) + m.start = time.Now().Add(-100 * time.Millisecond) + + m, _ = m.Update(StartStopMsg{ID: m.id, running: false}) + + if m.Elapsed() < 90*time.Millisecond { + t.Fatalf("Elapsed() after stop = %v, want at least 90ms", m.Elapsed()) + } + if m.running { + t.Fatal("expected stopwatch to be stopped") + } +}