diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fe4408f..08e39ca 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -32,7 +32,7 @@ repos: - id: test name: Run zig build test entry: zig - args: ["build", "coverage", "-Dcoverage-threshold=74"] + args: ["build", "coverage", "-Dcoverage-threshold=75"] language: system types: [file] pass_filenames: false diff --git a/src/tui.zig b/src/tui.zig index 385f0f3..db71d99 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -527,6 +527,11 @@ pub const App = struct { symbol_owned: bool = false, scroll_offset: usize = 0, visible_height: u16 = 24, // updated each draw + /// Monotonic counter incremented each draw frame. Passed to the + /// active tab's `tick` hook so polling-based async work (future + /// observation engine async dispatch, etc.) has a wall-clock- + /// independent tick to react to. + frame: u64 = 0, has_explicit_symbol: bool = false, // true if -s was used @@ -661,11 +666,11 @@ pub const App = struct { switch (mouse.button) { .wheel_up => { - self.moveBy(-3); + self.wheelBy(-3); return ctx.consumeAndRedraw(); }, .wheel_down => { - self.moveBy(3); + self.wheelBy(3); return ctx.consumeAndRedraw(); }, .left => { @@ -1141,23 +1146,57 @@ pub const App = struct { return false; } - /// Move cursor/scroll. Positive = down, negative = up. - /// For tabs with a row cursor, moves the cursor by 1 with - /// debounce to absorb duplicate events from mouse wheel ticks. - /// For other tabs (or cursor-bearing tabs with empty rows), - /// adjusts scroll_offset by |n|. + /// Keyboard cursor movement (`j`/`k`/arrows). Positive = down, + /// negative = up, magnitude = 1 per keypress. For tabs with a + /// row cursor, moves the cursor; for tabs without one (or with + /// empty rows), adjusts scroll_offset. + /// + /// No debounce — keyboard input isn't bursty the way wheel + /// events are. fn moveBy(self: *App, n: isize) void { - // Migrated cursor-bearing tabs (portfolio, options, history). - // The hook returns false when it has no rows, so we fall - // through to scroll. Debounce applies to the cursor-move - // path only — preserving legacy behavior where wheel - // events on non-cursor views scroll without debounce. if (self.activeTabHas("onCursorMove")) { - if (self.shouldDebounceWheel()) return; if (self.dispatchBool("onCursorMove", .{n})) return; // Hook declined (empty rows) — fall through to scroll. } - // Non-cursor tabs: scroll the viewport directly. + self.scrollViewportBy(n); + } + + /// Mouse-wheel-driven movement. Positive = down, negative = up, + /// magnitude = lines per detent (typically 3). + /// + /// Dispatch order: + /// 1. `onWheelMove` if declared — tab decides what wheel means + /// (review tab uses this to ALWAYS scroll viewport rather + /// than mix with cursor movement). + /// 2. `onCursorMove` — legacy "wheel moves cursor" behavior + /// preserved for tabs that don't declare `onWheelMove` + /// (portfolio, options, history at time of writing). + /// 3. Viewport scroll — fallback when the active tab has no + /// cursor or the cursor hook declines (empty rows). + /// + /// Debounce applies — terminals typically batch 3-5 wheel events + /// per physical detent and we don't want to act on each one. + fn wheelBy(self: *App, n: isize) void { + if (self.activeTabHas("onWheelMove")) { + if (self.shouldDebounceWheel()) return; + if (self.dispatchBool("onWheelMove", .{n})) return; + // Tab handled wheel as viewport scroll (returned false); + // fall through. + self.scrollViewportBy(n); + return; + } + if (self.activeTabHas("onCursorMove")) { + if (self.shouldDebounceWheel()) return; + if (self.dispatchBool("onCursorMove", .{n})) return; + } + self.scrollViewportBy(n); + } + + /// Adjust the App-level scroll_offset by `n` (signed, positive = + /// down, negative = up). Clamps at 0; no upper bound (the draw + /// path handles overflow visually). Shared by `moveBy` and + /// `wheelBy` as their fallback. + fn scrollViewportBy(self: *App, n: isize) void { if (n > 0) { self.scroll_offset += @intCast(n); } else { @@ -1586,6 +1625,13 @@ pub const App = struct { const self: *App = @ptrCast(@alignCast(ptr)); const max_size = ctx.max.size(); + // Per-frame tick dispatch to the active tab. Tabs that + // don't declare a `tick` hook fall through to the + // framework's `noopTick`. Used for polling-based async + // work (future observation engine async dispatch, etc.). + self.frame +%= 1; + self.dispatchVoid("tick", .{self.frame}); + if (max_size.height < 3) { return .{ .size = max_size, .widget = self.widget(), .buffer = &.{}, .children = &.{} }; } diff --git a/src/tui/history_tab.zig b/src/tui/history_tab.zig index 7cc01a7..d7775d4 100644 --- a/src/tui/history_tab.zig +++ b/src/tui/history_tab.zig @@ -152,6 +152,8 @@ pub const State = struct { /// Per-bucket expansion set. Keyed by `BucketKey` (tier + days) /// to disambiguate edge-aligned parents and children. Initialized /// in `init` (requires an allocator). + // SAFETY: overwritten by `init()` before any read; the framework + // contract guarantees `init` runs before `activate`/draw paths. expanded_buckets: std.AutoHashMap(BucketKey, void) = undefined, }; @@ -288,6 +290,17 @@ pub const tab = struct { return true; } + /// Mouse wheel: always scroll the viewport, never move the + /// cursor. Keeps wheel-as-look-around and cursor-as-pointer + /// distinct. The framework falls through to viewport scroll + /// when this returns false. + pub fn onWheelMove(state: *State, app: *App, delta: isize) bool { + _ = state; + _ = app; + _ = delta; + return false; + } + /// Mouse handling: a left-click on a row moves the cursor and /// toggles tier expansion (no-op for non-tier rows). Returns /// `true` if the click landed on a data row in the recent- diff --git a/src/tui/options_tab.zig b/src/tui/options_tab.zig index 564bc6f..5f7aaae 100644 --- a/src/tui/options_tab.zig +++ b/src/tui/options_tab.zig @@ -256,6 +256,17 @@ pub const tab = struct { ensureCursorVisible(state, &app.scroll_offset, app.visible_height); return true; } + + /// Mouse wheel: always scroll the viewport, never move the + /// cursor. Keeps wheel-as-look-around and cursor-as-pointer + /// distinct. The framework falls through to viewport scroll + /// when this returns false. + pub fn onWheelMove(state: *State, app: *App, delta: isize) bool { + _ = state; + _ = app; + _ = delta; + return false; + } }; // ── Cursor movement / visibility (private; called from onCursorMove) ── diff --git a/src/tui/portfolio_tab.zig b/src/tui/portfolio_tab.zig index 003129e..e6a4b74 100644 --- a/src/tui/portfolio_tab.zig +++ b/src/tui/portfolio_tab.zig @@ -420,6 +420,17 @@ pub const tab = struct { return true; } + /// Mouse wheel: always scroll the viewport, never move the + /// cursor. Keeps wheel-as-look-around and cursor-as-pointer + /// distinct. The framework falls through to viewport scroll + /// when this returns false. + pub fn onWheelMove(state: *State, app: *App, delta: isize) bool { + _ = state; + _ = app; + _ = delta; + return false; + } + /// Pre-empt key handler. Called by the framework BEFORE /// global keymap matching runs. When portfolio is in a /// modal sub-state (`state.modal != .none`) we route to the diff --git a/src/tui/tab_framework.zig b/src/tui/tab_framework.zig index f301816..aa578dc 100644 --- a/src/tui/tab_framework.zig +++ b/src/tui/tab_framework.zig @@ -68,16 +68,40 @@ //! /// this hook. //! pub fn onScroll(state: *State, app: *App, where: ScrollEdge) void { ... } //! -//! /// Fired when the user invokes a relative cursor-move -//! /// (`j`/`k`, ↑/↓, mouse wheel). `delta` is signed: positive -//! /// = down, negative = up. Magnitude is 1 for keys, larger -//! /// for wheel events. Tabs with a row cursor step it, -//! /// clamp to row count, and ensure visibility; return -//! /// `true` to consume. Tabs without a cursor (or with empty -//! /// rows) return `false` so the framework falls through to -//! /// scroll-by-`delta` instead. +//! /// Fired when the user invokes a relative cursor-move via +//! /// keyboard (`j`/`k`, ↑/↓). `delta` is signed: positive = +//! /// down, negative = up. Magnitude is 1 per keypress. Tabs +//! /// with a row cursor step it, clamp to row count, and +//! /// ensure visibility; return `true` to consume. Tabs without +//! /// a cursor (or with empty rows) return `false` so the +//! /// framework falls through to scroll-by-`delta` instead. +//! /// +//! /// **Mouse wheel events go through `onWheelMove`, NOT this +//! /// hook.** A tab that wants wheel-as-cursor-move (the legacy +//! /// portfolio-tab convention) implements `onWheelMove` to +//! /// delegate to `onCursorMove`. A tab that wants +//! /// wheel-as-viewport-scroll (the cleaner default for tabs +//! /// with multiple cursor regions) declines `onWheelMove` and +//! /// the framework scrolls instead. //! pub fn onCursorMove(state: *State, app: *App, delta: isize) bool { ... } //! +//! /// Fired when the user wheels the mouse. `delta` is signed: +//! /// positive = down, negative = up. Magnitude is whatever the +//! /// terminal reports per wheel detent (typically 3-5 lines on +//! /// most platforms; the framework already debounces). +//! /// +//! /// Return `true` to consume; return `false` to fall through +//! /// to viewport scroll. If a tab omits this hook entirely, +//! /// the framework's default behavior is to delegate to +//! /// `onCursorMove` — which preserves the legacy +//! /// "wheel moves cursor" behavior for single-cursor tabs. +//! /// +//! /// New multi-region tabs (e.g. review tab with separate +//! /// holdings + findings tables) should declare this hook and +//! /// return `false` so wheel always scrolls the viewport, +//! /// reserving cursor movement for keyboard and click. +//! pub fn onWheelMove(state: *State, app: *App, delta: isize) bool { ... } +//! //! // ── Misc (required) ───────────────────────────────────── //! pub fn isDisabled(app: *App) bool { ... } //! }; @@ -514,6 +538,16 @@ pub fn validateTabModule(comptime Module: type) void { "pub fn onCursorMove(state: *State, app: *App, delta: isize) bool { ... }", ); } + if (@hasDecl(tab_decl, "onWheelMove")) { + validator.expectFn( + "Tab module", + mod_name, + tab_decl, + "onWheelMove", + fn (*State, *App, isize) bool, + "pub fn onWheelMove(state: *State, app: *App, delta: isize) bool { ... }", + ); + } } }