separate keyboard scroll from mouse scroll
This commit is contained in:
parent
a48dc47837
commit
fcdfa8437f
6 changed files with 138 additions and 23 deletions
|
|
@ -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
|
||||
|
|
|
|||
74
src/tui.zig
74
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 = &.{} };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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-
|
||||
|
|
|
|||
|
|
@ -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) ──
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 { ... }",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue