separate keyboard scroll from mouse scroll

This commit is contained in:
Emil Lerch 2026-06-09 11:22:49 -07:00
parent a48dc47837
commit fcdfa8437f
Signed by: lobo
GPG key ID: A7B62D657EF764F8
6 changed files with 138 additions and 23 deletions

View file

@ -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

View file

@ -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 = &.{} };
}

View file

@ -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-

View file

@ -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)

View file

@ -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

View file

@ -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 { ... }",
);
}
}
}