diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-09 11:11:01 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-09 11:11:01 +0300 |
| commit | eed407b0e252a0105619daf79b8bc236ff5f487d (patch) | |
| tree | ec2b1e6feb79d1eb2e9b4f10b5463cdc01f552db | |
| parent | 8da473aed2c3e901615294df398b26db5aea6032 (diff) | |
refine auto-reset timer cycle and pause on blur
Two follow-up refinements to the auto-reset timer added in 8da473a.
- Hotkey cycle now goes off -> 10s -> 30s -> 60s -> 2m -> 5m -> off,
giving the user finer control between 60s and 5m and a quicker
starting cadence.
- The timer now pauses while the TUI is blurred. SetFocused returns a
tea.Cmd that re-arms a fresh tick on focus regain, and bumps the
generation counter on every focus change so any tick scheduled before
blur is dropped on arrival. autoResetTickCmd and handleAutoResetTick
also gate on m.focused as defense in depth.
- Dashboard chrome shows 'auto-reset: 30s (paused)' while the timer is
enabled but blurred, distinguishing it from the disabled 'off' state.
Tests cover the full preset cycle (including custom-value passthrough)
and the pause-on-blur lifecycle: stale ticks ignored, current-gen ticks
ignored while blurred, focus regain re-arms and fires the reset, no-op
focus calls don't churn the generation counter, and the chrome label
flips to '(paused)' as expected.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
| -rw-r--r-- | internal/tui/dashboard/model.go | 37 | ||||
| -rw-r--r-- | internal/tui/dashboard/model_test.go | 137 | ||||
| -rw-r--r-- | internal/tui/tui.go | 19 | ||||
| -rw-r--r-- | internal/tui/tui_test.go | 52 |
4 files changed, 235 insertions, 10 deletions
diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go index 0437f05..478e735 100644 --- a/internal/tui/dashboard/model.go +++ b/internal/tui/dashboard/model.go @@ -916,9 +916,11 @@ func (m *Model) resetBaselineCmd() tea.Cmd { // autoResetTickCmd returns a command that fires an autoResetTickMsg after // the current auto-reset interval. Returns nil when the timer is disabled -// (interval <= 0), so callers can compose it without extra branching. +// (interval <= 0) or while the dashboard is blurred, so callers can +// compose it without extra branching. SetFocused re-arms the tick when +// focus returns. func (m Model) autoResetTickCmd() tea.Cmd { - if m.autoResetEvery <= 0 { + if m.autoResetEvery <= 0 || !m.focused { return nil } gen := m.autoResetGen @@ -930,9 +932,11 @@ func (m Model) autoResetTickCmd() tea.Cmd { // handleAutoResetTick fires the same reset path as the `r` key (live trie // + stats engine) and re-arms the timer for the next tick. Stale ticks // from a previous cadence are dropped via the generation counter so that -// changing the interval does not double-fire. +// changing the interval does not double-fire. While the dashboard is +// blurred the tick is also dropped without re-arming; SetFocused will +// arm a fresh tick on focus regain. func (m Model) handleAutoResetTick(msg autoResetTickMsg) (tea.Model, tea.Cmd) { - if msg.generation != m.autoResetGen || m.autoResetEvery <= 0 { + if msg.generation != m.autoResetGen || m.autoResetEvery <= 0 || !m.focused { return m, nil } resetCmd := m.resetBaselineCmd() @@ -1048,9 +1052,24 @@ func (m *Model) SetDarkMode(isDark bool) { m.processesChart.SetDarkMode(isDark) } -// SetFocused controls whether periodic refresh ticks are processed. -func (m *Model) SetFocused(focused bool) { +// SetFocused controls whether periodic refresh ticks are processed and +// returns a tea.Cmd that arms a fresh auto-reset tick when focus returns +// (or nil otherwise). The auto-reset generation counter is bumped on +// every focus change so any in-flight tick scheduled before a blur is +// dropped when it eventually arrives — the tick payload's generation +// will no longer match. Without bumping, a tick that was already in +// flight when blur occurred could fire moments after the user re-focuses +// and surprise them with a reset. +func (m *Model) SetFocused(focused bool) tea.Cmd { + if m.focused == focused { + return nil + } m.focused = focused + m.autoResetGen++ + if !focused { + return nil + } + return m.autoResetTickCmd() } // SnapshotCmd returns a command that fetches and emits a fresh dashboard snapshot. @@ -1106,10 +1125,16 @@ func (m Model) filterSummary() string { // autoResetStatus is the human-readable label for the current // auto-reset cadence shown in the dashboard chrome. "off" when the // timer is disabled, otherwise the configured interval (e.g. "30s"). +// When the timer is enabled but the TUI has lost focus, a "(paused)" +// suffix is appended so the user knows the timer will not fire until +// focus returns. Disabled timers stay "off" regardless of focus. func (m Model) autoResetStatus() string { if m.autoResetEvery <= 0 { return "auto-reset: off" } + if !m.focused { + return "auto-reset: " + m.autoResetEvery.String() + " (paused)" + } return "auto-reset: " + m.autoResetEvery.String() } diff --git a/internal/tui/dashboard/model_test.go b/internal/tui/dashboard/model_test.go index 8c73230..2fb59f5 100644 --- a/internal/tui/dashboard/model_test.go +++ b/internal/tui/dashboard/model_test.go @@ -6,6 +6,7 @@ import ( "strconv" "strings" "testing" + "time" coreflamegraph "ior/internal/flamegraph" "ior/internal/statsengine" @@ -1587,3 +1588,139 @@ func TestTranslateFlamegraphMsgLeavesNonMouseUnchanged(t *testing.T) { t.Fatalf("expected non-mouse message to remain unchanged, got %T", translated) } } + +// TestAutoResetTickIgnoredWhileBlurred drives the same tick the +// tea.Tick scheduler would deliver after the cadence elapses, but +// from a blurred state. The expected behavior is: no reset fires +// (no Reset() call on the engine), and no new tick is re-armed — +// SetFocused will arm a fresh one when focus returns. +func TestAutoResetTickIgnoredWhileBlurred(t *testing.T) { + engine := &fakeResettableSnapshotSource{} + m := NewModelWithConfig(engine, nil, 250, common.DefaultKeyMap()) + if cmd := m.SetAutoResetInterval(50 * time.Millisecond); cmd == nil { + t.Fatalf("SetAutoResetInterval should return a tick command for a positive interval") + } + gen := m.autoResetGen + + // Simulate blur. The returned cmd must be nil (no rearm). + if cmd := m.SetFocused(false); cmd != nil { + t.Fatalf("SetFocused(false) should not return a tick command, got %v", cmd) + } + if m.autoResetGen == gen { + t.Fatalf("SetFocused(false) should bump autoResetGen so in-flight ticks are dropped") + } + + // Deliver the in-flight tick that was scheduled before the blur. It + // carries the pre-blur generation, so it must be silently dropped. + staleTick := autoResetTickMsg{generation: gen} + next, cmd := m.Update(staleTick) + m = next.(Model) + if cmd != nil { + t.Fatalf("blurred dashboard should not re-arm the timer on a stale tick, got %v", cmd) + } + if engine.resetCount != 0 { + t.Fatalf("blurred dashboard should not reset the engine, got resetCount=%d", engine.resetCount) + } + + // Even a tick crafted with the current generation must not fire + // while blurred — handleAutoResetTick gates on m.focused. + currentTick := autoResetTickMsg{generation: m.autoResetGen} + next, cmd = m.Update(currentTick) + m = next.(Model) + if cmd != nil { + t.Fatalf("blurred dashboard must not re-arm even on a current-gen tick, got %v", cmd) + } + if engine.resetCount != 0 { + t.Fatalf("blurred dashboard must not reset on a current-gen tick, got resetCount=%d", engine.resetCount) + } +} + +// TestAutoResetTickResumesOnFocusRegain checks that focus regain arms +// a fresh tick at the configured cadence, and that the next tick fires +// the reset path. We deliver the tick by direct injection (the same +// payload tea.Tick would deliver) rather than waiting on real time. +func TestAutoResetTickResumesOnFocusRegain(t *testing.T) { + engine := &fakeResettableSnapshotSource{} + m := NewModelWithConfig(engine, nil, 250, common.DefaultKeyMap()) + m.SetAutoResetInterval(50 * time.Millisecond) + m.SetFocused(false) + + // Focus regain must return a non-nil tick cmd because the timer is + // still configured, and bump the generation again. + preGen := m.autoResetGen + cmd := m.SetFocused(true) + if cmd == nil { + t.Fatalf("SetFocused(true) should return a fresh tick cmd when timer is enabled") + } + if m.autoResetGen == preGen { + t.Fatalf("SetFocused(true) should bump autoResetGen to invalidate any leftover ticks") + } + + // Deliver a tick at the post-regain generation: the reset must fire + // and a fresh tick must be re-armed for the next interval. + tick := autoResetTickMsg{generation: m.autoResetGen} + next, cmd := m.Update(tick) + m = next.(Model) + if cmd == nil { + t.Fatalf("focused dashboard should re-arm timer and emit reset cmd, got nil") + } + if engine.resetCount != 1 { + t.Fatalf("focused tick should reset engine exactly once, got resetCount=%d", engine.resetCount) + } +} + +// TestSetFocusedNoOpWhenStateUnchanged guards against accidental +// generation churn when focus messages arrive without an actual state +// change (e.g. a duplicate FocusMsg). Bumping the generation in that +// case would silently invalidate a healthy in-flight tick. +func TestSetFocusedNoOpWhenStateUnchanged(t *testing.T) { + m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap()) + m.SetAutoResetInterval(50 * time.Millisecond) + gen := m.autoResetGen + + if cmd := m.SetFocused(true); cmd != nil { + t.Fatalf("SetFocused(true) on already-focused model should be a no-op, got %v", cmd) + } + if m.autoResetGen != gen { + t.Fatalf("autoResetGen should not change on no-op focus call, was %d now %d", gen, m.autoResetGen) + } +} + +// TestSetFocusedReturnsNilWhenTimerDisabled is the corner case where +// focus returns but the user has the auto-reset timer turned off. No +// tick should be armed (it would never fire anyway). +func TestSetFocusedReturnsNilWhenTimerDisabled(t *testing.T) { + m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap()) + // Timer disabled by default. + m.SetFocused(false) + if cmd := m.SetFocused(true); cmd != nil { + t.Fatalf("SetFocused(true) with disabled timer should return nil, got %v", cmd) + } +} + +// TestAutoResetStatusAddsPausedSuffixWhenBlurred locks in the chrome +// label contract: enabled+focused -> "auto-reset: 30s", +// enabled+blurred -> "auto-reset: 30s (paused)", disabled stays "off" +// regardless of focus. +func TestAutoResetStatusAddsPausedSuffixWhenBlurred(t *testing.T) { + m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap()) + m.SetAutoResetInterval(30 * time.Second) + if got, want := m.autoResetStatus(), "auto-reset: 30s"; got != want { + t.Fatalf("focused enabled status = %q, want %q", got, want) + } + + m.SetFocused(false) + if got, want := m.autoResetStatus(), "auto-reset: 30s (paused)"; got != want { + t.Fatalf("blurred enabled status = %q, want %q", got, want) + } + + m.SetAutoResetInterval(0) + if got, want := m.autoResetStatus(), "auto-reset: off"; got != want { + t.Fatalf("blurred disabled status = %q, want %q", got, want) + } + + m.SetFocused(true) + if got, want := m.autoResetStatus(), "auto-reset: off"; got != want { + t.Fatalf("focused disabled status = %q, want %q", got, want) + } +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index e1d5581..63321ac 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -481,13 +481,22 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case tea.FocusMsg: m.focused = true - m.dashboard.SetFocused(true) + // SetFocused returns a tea.Cmd that arms a fresh auto-reset tick + // when focus returns (or nil if the timer is disabled). It also + // bumps the dashboard's autoResetGen so any tick that was scheduled + // before the blur and is still in flight is dropped on arrival. + focusCmd := m.dashboard.SetFocused(true) if m.screen == ScreenDashboard && !m.attaching { + // Init() arms its own auto-reset tick at the post-bump + // generation, so discard focusCmd here to avoid two + // concurrently-live ticks racing the cadence. return m, tea.Batch(m.dashboard.Init(), m.dashboard.SnapshotCmd()) } - return m, nil + return m, focusCmd case tea.BlurMsg: m.focused = false + // SetFocused returns nil on blur but still bumps autoResetGen so + // that any in-flight tick scheduled before the blur is ignored. m.dashboard.SetFocused(false) return m, nil case tea.KeyPressMsg: @@ -666,12 +675,14 @@ func (m Model) handleGlobalKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd, bo // autoResetCycle is the ordered set of cadences exposed via the `I` // hotkey. The first entry (0) disables the timer; the rest are // progressively longer to give users a quick way to slow auto-resets -// down on long traces or turn them off entirely. +// down on long traces or turn them off entirely. The cycle wraps so +// pressing `I` past the last preset returns to off. var autoResetCycle = []time.Duration{ 0, - 15 * time.Second, + 10 * time.Second, 30 * time.Second, 60 * time.Second, + 2 * time.Minute, 5 * time.Minute, } diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 27bf041..b8d6108 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -2130,3 +2130,55 @@ func TestGlobalHelpOverlayFitsStandardTerminal(t *testing.T) { t.Fatalf("expected overlay to include bubble dashboard hotkeys") } } + +// TestNextAutoResetIntervalCyclesThroughPresets walks the full preset +// sequence (off -> 10s -> 30s -> 60s -> 2m -> 5m -> off) to lock in the +// user-facing behavior of the `I` hotkey. The cycle wraps so the user +// can press `I` repeatedly without overshooting into surprise states. +func TestNextAutoResetIntervalCyclesThroughPresets(t *testing.T) { + t.Parallel() + cases := []struct { + name string + in time.Duration + want time.Duration + }{ + {"off->10s", 0, 10 * time.Second}, + {"10s->30s", 10 * time.Second, 30 * time.Second}, + {"30s->60s", 30 * time.Second, 60 * time.Second}, + {"60s->2m", 60 * time.Second, 2 * time.Minute}, + {"2m->5m", 2 * time.Minute, 5 * time.Minute}, + {"5m->off", 5 * time.Minute, 0}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := nextAutoResetInterval(tc.in) + if got != tc.want { + t.Fatalf("nextAutoResetInterval(%s) = %s, want %s", tc.in, got, tc.want) + } + }) + } +} + +// TestNextAutoResetIntervalAdvancesCustomValueToNextPreset covers the +// custom-value passthrough: when the user passes a -resetTimer value +// that is not in the preset cycle (e.g. 47s), pressing `I` should jump +// to the smallest preset strictly greater than the current cadence +// rather than restarting from the top of the cycle. +func TestNextAutoResetIntervalAdvancesCustomValueToNextPreset(t *testing.T) { + t.Parallel() + if got := nextAutoResetInterval(47 * time.Second); got != 60*time.Second { + t.Fatalf("nextAutoResetInterval(47s) = %s, want 60s", got) + } + if got := nextAutoResetInterval(90 * time.Second); got != 2*time.Minute { + t.Fatalf("nextAutoResetInterval(90s) = %s, want 2m", got) + } + if got := nextAutoResetInterval(3 * time.Minute); got != 5*time.Minute { + t.Fatalf("nextAutoResetInterval(3m) = %s, want 5m", got) + } + // A custom value larger than every preset wraps to off. + if got := nextAutoResetInterval(10 * time.Minute); got != 0 { + t.Fatalf("nextAutoResetInterval(10m) = %s, want 0 (off)", got) + } +} |
