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
|
- id: test
|
||||||
name: Run zig build test
|
name: Run zig build test
|
||||||
entry: zig
|
entry: zig
|
||||||
args: ["build", "coverage", "-Dcoverage-threshold=74"]
|
args: ["build", "coverage", "-Dcoverage-threshold=75"]
|
||||||
language: system
|
language: system
|
||||||
types: [file]
|
types: [file]
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
|
|
|
||||||
74
src/tui.zig
74
src/tui.zig
|
|
@ -527,6 +527,11 @@ pub const App = struct {
|
||||||
symbol_owned: bool = false,
|
symbol_owned: bool = false,
|
||||||
scroll_offset: usize = 0,
|
scroll_offset: usize = 0,
|
||||||
visible_height: u16 = 24, // updated each draw
|
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
|
has_explicit_symbol: bool = false, // true if -s was used
|
||||||
|
|
||||||
|
|
@ -661,11 +666,11 @@ pub const App = struct {
|
||||||
|
|
||||||
switch (mouse.button) {
|
switch (mouse.button) {
|
||||||
.wheel_up => {
|
.wheel_up => {
|
||||||
self.moveBy(-3);
|
self.wheelBy(-3);
|
||||||
return ctx.consumeAndRedraw();
|
return ctx.consumeAndRedraw();
|
||||||
},
|
},
|
||||||
.wheel_down => {
|
.wheel_down => {
|
||||||
self.moveBy(3);
|
self.wheelBy(3);
|
||||||
return ctx.consumeAndRedraw();
|
return ctx.consumeAndRedraw();
|
||||||
},
|
},
|
||||||
.left => {
|
.left => {
|
||||||
|
|
@ -1141,23 +1146,57 @@ pub const App = struct {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Move cursor/scroll. Positive = down, negative = up.
|
/// Keyboard cursor movement (`j`/`k`/arrows). Positive = down,
|
||||||
/// For tabs with a row cursor, moves the cursor by 1 with
|
/// negative = up, magnitude = 1 per keypress. For tabs with a
|
||||||
/// debounce to absorb duplicate events from mouse wheel ticks.
|
/// row cursor, moves the cursor; for tabs without one (or with
|
||||||
/// For other tabs (or cursor-bearing tabs with empty rows),
|
/// empty rows), adjusts scroll_offset.
|
||||||
/// adjusts scroll_offset by |n|.
|
///
|
||||||
|
/// No debounce — keyboard input isn't bursty the way wheel
|
||||||
|
/// events are.
|
||||||
fn moveBy(self: *App, n: isize) void {
|
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.activeTabHas("onCursorMove")) {
|
||||||
if (self.shouldDebounceWheel()) return;
|
|
||||||
if (self.dispatchBool("onCursorMove", .{n})) return;
|
if (self.dispatchBool("onCursorMove", .{n})) return;
|
||||||
// Hook declined (empty rows) — fall through to scroll.
|
// 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) {
|
if (n > 0) {
|
||||||
self.scroll_offset += @intCast(n);
|
self.scroll_offset += @intCast(n);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -1586,6 +1625,13 @@ pub const App = struct {
|
||||||
const self: *App = @ptrCast(@alignCast(ptr));
|
const self: *App = @ptrCast(@alignCast(ptr));
|
||||||
const max_size = ctx.max.size();
|
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) {
|
if (max_size.height < 3) {
|
||||||
return .{ .size = max_size, .widget = self.widget(), .buffer = &.{}, .children = &.{} };
|
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)
|
/// Per-bucket expansion set. Keyed by `BucketKey` (tier + days)
|
||||||
/// to disambiguate edge-aligned parents and children. Initialized
|
/// to disambiguate edge-aligned parents and children. Initialized
|
||||||
/// in `init` (requires an allocator).
|
/// 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,
|
expanded_buckets: std.AutoHashMap(BucketKey, void) = undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -288,6 +290,17 @@ pub const tab = struct {
|
||||||
return true;
|
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
|
/// Mouse handling: a left-click on a row moves the cursor and
|
||||||
/// toggles tier expansion (no-op for non-tier rows). Returns
|
/// toggles tier expansion (no-op for non-tier rows). Returns
|
||||||
/// `true` if the click landed on a data row in the recent-
|
/// `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);
|
ensureCursorVisible(state, &app.scroll_offset, app.visible_height);
|
||||||
return true;
|
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) ──
|
// ── Cursor movement / visibility (private; called from onCursorMove) ──
|
||||||
|
|
|
||||||
|
|
@ -420,6 +420,17 @@ pub const tab = struct {
|
||||||
return true;
|
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
|
/// Pre-empt key handler. Called by the framework BEFORE
|
||||||
/// global keymap matching runs. When portfolio is in a
|
/// global keymap matching runs. When portfolio is in a
|
||||||
/// modal sub-state (`state.modal != .none`) we route to the
|
/// modal sub-state (`state.modal != .none`) we route to the
|
||||||
|
|
|
||||||
|
|
@ -68,16 +68,40 @@
|
||||||
//! /// this hook.
|
//! /// this hook.
|
||||||
//! pub fn onScroll(state: *State, app: *App, where: ScrollEdge) void { ... }
|
//! pub fn onScroll(state: *State, app: *App, where: ScrollEdge) void { ... }
|
||||||
//!
|
//!
|
||||||
//! /// Fired when the user invokes a relative cursor-move
|
//! /// Fired when the user invokes a relative cursor-move via
|
||||||
//! /// (`j`/`k`, ↑/↓, mouse wheel). `delta` is signed: positive
|
//! /// keyboard (`j`/`k`, ↑/↓). `delta` is signed: positive =
|
||||||
//! /// = down, negative = up. Magnitude is 1 for keys, larger
|
//! /// down, negative = up. Magnitude is 1 per keypress. Tabs
|
||||||
//! /// for wheel events. Tabs with a row cursor step it,
|
//! /// with a row cursor step it, clamp to row count, and
|
||||||
//! /// clamp to row count, and ensure visibility; return
|
//! /// ensure visibility; return `true` to consume. Tabs without
|
||||||
//! /// `true` to consume. Tabs without a cursor (or with empty
|
//! /// a cursor (or with empty rows) return `false` so the
|
||||||
//! /// rows) return `false` so the framework falls through to
|
//! /// framework falls through to scroll-by-`delta` instead.
|
||||||
//! /// 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 { ... }
|
//! 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) ─────────────────────────────────────
|
//! // ── Misc (required) ─────────────────────────────────────
|
||||||
//! pub fn isDisabled(app: *App) bool { ... }
|
//! 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 { ... }",
|
"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