From c787d59a987ec601cd7b65399543afe6d37a615d Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 15 Sep 2026 12:43:53 -0300 Subject: Route mouse thumb buttons to jump history --- docs/helix-keys.md | 2 +- src/detached/wire.zig | 2 + src/gui/gui.zig | 2 + src/macos.zig | 4 ++ src/macos/Sources/PardesView.swift | 24 +++++++++--- src/macos/pardes.h | 2 + src/pardes.zig | 31 ++++++++++++++- src/web.zig | 2 + src/web/app.mjs | 27 +++++++++++-- test/web-snapshots/touch.mjs | 78 ++++++++++++++++++++++++++++++++++++-- test/web_snapshot.mjs | 3 +- 11 files changed, 160 insertions(+), 17 deletions(-) diff --git a/docs/helix-keys.md b/docs/helix-keys.md index c3724cd2..ccb92ad6 100644 --- a/docs/helix-keys.md +++ b/docs/helix-keys.md @@ -134,7 +134,7 @@ language-backend queries, and the shell pipe. | `gd` `gD` `gy` `gi` `gr` | LSP definition / declaration / type-definition / implementation / references. ONE answer jumps straight there; several fill `+Search`, where n/N walk and Enter opens | in-process ZLS (`src/lsp/lsp_zls.zig`), `.zig` only — on a file the backend does not speak these do nothing at all, with no error row. Ctrl+left-click is the mouse spelling of `gd` | out of corpus | | `]d` `[d` / `]D` `[D` | step the diagnostics list / go to its last or first; if no list is up, asking the backend for one is part of the press | | out of corpus | | `=` | `format_selections` — writes a `- old` / `+ new` diff into `+Lsp` | deliberate divergence: the seam returns ROWS, not edits, so this SHOWS the formatting instead of applying it. Not in the corpus, so there is no waiver to name — the query leaves the core as an effect the headless harness has no shell to perform | out of corpus | -| `Ctrl-o` / `Ctrl-i` | jumplist back / forward; selections remain unchanged (a selected cursor stays at its selection while the view visits the saved location); raw tty forwards both to the child — the `Back` / `Forward` builtins, also on `SPC j o` / `SPC j i`, with `SPC j l` rendering the stack as a buffer | helix binds both keys (`jump_backward` / `jump_forward`) but to a POSITION jumplist; pardes' stack is over panes and focus, so the keys agree and the semantics do not. `Ctrl-i` and Tab are the same byte under the legacy encoding; there Tab keeps meaning execute, and the pair only separates where the host speaks the kitty keyboard protocol | pardes-specific | +| `Ctrl-o` / `Ctrl-i` | jumplist back / forward (also Mouse4 / Mouse5 in SDL, macOS and web); selections remain unchanged (a selected cursor stays at its selection while the view visits the saved location); raw tty forwards both to the child — the `Back` / `Forward` builtins, also on `SPC j o` / `SPC j i`, with `SPC j l` rendering the stack as a buffer | helix binds both keys (`jump_backward` / `jump_forward`) but to a POSITION jumplist; pardes' stack is over panes and focus, so the keys agree and the semantics do not. `Ctrl-i` and Tab are the same byte under the legacy encoding; there Tab keeps meaning execute, and the pair only separates where the host speaks the kitty keyboard protocol | pardes-specific | | `\|` | pipe every selection through `/bin/sh -c`: its bytes in on stdin, its stdout replacing them, one undo across all cursors | helix's own key and meaning; the command is typed into the pane's tag after a bare `\|` marker rather than into a popup | out of corpus | | `A-\|` | the same, and the output is DISCARDED — the text is not touched at all | helix `shell_pipe_to`. For a command run for its effect. Marker `\|-` | out of corpus | | `!` | run with NO stdin, insert the output BEFORE each selection | helix `shell_insert_output`. Runs ONCE and every cursor gets that one answer, as helix does — ten cursors and `date` give ten identical stamps. Marker `!` | out of corpus | diff --git a/src/detached/wire.zig b/src/detached/wire.zig index ab19bcfb..989991e0 100644 --- a/src/detached/wire.zig +++ b/src/detached/wire.zig @@ -81,6 +81,8 @@ const ButtonTag = enum(u8) { wheel_left = 0x05, wheel_right = 0x06, none = 0x07, + back = 0x08, + forward = 0x09, }; const KindTag = enum(u8) { press = 0x00, release = 0x01, motion = 0x02, drag = 0x03 }; diff --git a/src/gui/gui.zig b/src/gui/gui.zig index 082c8159..a0f0a3e4 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -3029,6 +3029,8 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void { 1 => .left, 2 => .middle, 3 => .right, + 4 => .back, + 5 => .forward, else => return, }; g.pad_x = b.x; diff --git a/src/macos.zig b/src/macos.zig index 96598b90..5c523acd 100644 --- a/src/macos.zig +++ b/src/macos.zig @@ -1490,6 +1490,8 @@ export fn pardes_mouse(button_arg: c_int, kind_arg: c_int, col: u16, row: u16, m 5 => .wheel_left, 6 => .wheel_right, 7 => .none, + 8 => .back, + 9 => .forward, else => return, }; const kind: pardes.Mouse.Kind = switch (kind_arg) { @@ -2365,6 +2367,8 @@ test "pardes.h matches the Zig boundary" { try expectEqual(c.PARDES_MOUSE_WHEEL_LEFT, @intFromEnum(pardes.Mouse.Button.wheel_left)); try expectEqual(c.PARDES_MOUSE_WHEEL_RIGHT, @intFromEnum(pardes.Mouse.Button.wheel_right)); try expectEqual(c.PARDES_MOUSE_NONE, @intFromEnum(pardes.Mouse.Button.none)); + try expectEqual(c.PARDES_MOUSE_BACK, @intFromEnum(pardes.Mouse.Button.back)); + try expectEqual(c.PARDES_MOUSE_FORWARD, @intFromEnum(pardes.Mouse.Button.forward)); try expectEqual(c.PARDES_MOUSE_PRESS, @intFromEnum(pardes.Mouse.Kind.press)); try expectEqual(c.PARDES_MOUSE_RELEASE, @intFromEnum(pardes.Mouse.Kind.release)); try expectEqual(c.PARDES_MOUSE_MOTION, @intFromEnum(pardes.Mouse.Kind.motion)); diff --git a/src/macos/Sources/PardesView.swift b/src/macos/Sources/PardesView.swift index 5c5bc8ba..bfc8a060 100644 --- a/src/macos/Sources/PardesView.swift +++ b/src/macos/Sources/PardesView.swift @@ -1730,11 +1730,25 @@ final class PardesView: NSView { override func rightMouseDown(with event: NSEvent) { beginClick(PARDES_MOUSE_RIGHT, event) } override func rightMouseDragged(with event: NSEvent) { continueClick(event) } override func rightMouseUp(with event: NSEvent) { endClick(event) } - // otherMouse covers button 2 and up. acme's vocabulary stops at three, so - // every one of them lands on middle rather than being invented into a fourth. - override func otherMouseDown(with event: NSEvent) { beginClick(PARDES_MOUSE_MIDDLE, event) } - override func otherMouseDragged(with event: NSEvent) { continueClick(event) } - override func otherMouseUp(with event: NSEvent) { endClick(event) } + // Thumb navigation has its own stream so it cannot replace a held + // selection button or inherit a resting trackpad's finger count. + override func otherMouseDown(with event: NSEvent) { + if event.buttonNumber == 2 { beginClick(PARDES_MOUSE_MIDDLE, event); return } + guard event.buttonNumber == 3 || event.buttonNumber == 4, + let at = cell(for: event) else { return } + press(event.buttonNumber == 3 ? PARDES_MOUSE_BACK : PARDES_MOUSE_FORWARD, + at: at, mods: pointerModifiers) + } + override func otherMouseDragged(with event: NSEvent) { + if event.buttonNumber == 2 { continueClick(event) } + } + override func otherMouseUp(with event: NSEvent) { + if event.buttonNumber == 2 { endClick(event); return } + guard event.buttonNumber == 3 || event.buttonNumber == 4, + let at = cell(for: event) else { return } + release(event.buttonNumber == 3 ? PARDES_MOUSE_BACK : PARDES_MOUSE_FORWARD, + at: at, mods: pointerModifiers) + } /// A deep press, which is a second gesture layered on the click already in /// flight. AppKit keeps sending stage-2 events while the finger stays down, diff --git a/src/macos/pardes.h b/src/macos/pardes.h index 845c8096..546273d9 100644 --- a/src/macos/pardes.h +++ b/src/macos/pardes.h @@ -167,6 +167,8 @@ typedef enum { PARDES_MOUSE_WHEEL_LEFT = 5, PARDES_MOUSE_WHEEL_RIGHT = 6, PARDES_MOUSE_NONE = 7, + PARDES_MOUSE_BACK = 8, + PARDES_MOUSE_FORWARD = 9, } pardes_mouse_button_e; typedef enum { diff --git a/src/pardes.zig b/src/pardes.zig index 1dbe2e81..efd82474 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -4314,7 +4314,7 @@ test "normal header Enter executes its command rather than searching for it" { pub const Mouse = struct { // sel indexing relies on left/middle/right being 0/1/2 - pub const Button = enum { left, middle, right, wheel_up, wheel_down, wheel_left, wheel_right, none }; + pub const Button = enum { left, middle, right, wheel_up, wheel_down, wheel_left, wheel_right, none, back, forward }; pub const Kind = enum { press, release, motion, drag }; button: Button, kind: Kind, @@ -10747,6 +10747,10 @@ pub const Pardes = struct { } fn handleMouse(p: *Pardes, m: Mouse) void { + if (m.button == .back or m.button == .forward) { + if (m.kind == .press) p.jumpBy(if (m.button == .back) -1 else 1); + return; + } const raw_col = @min(m.col, p.screen_w -| 1); const raw_row = @min(m.row, p.screen_h -| 1); p.pointer_raw_col = raw_col; @@ -10842,7 +10846,7 @@ pub const Pardes = struct { } switch (m.button) { - .none => {}, // hover tracked above; motion has no other meaning + .none, .back, .forward => {}, // hover and navigation handled above .wheel_up, .wheel_down => { if (m.kind != .press) return; // re-arms the drift guard below; vertical always scrolls, so @@ -13948,3 +13952,26 @@ test "jump history terminal rectangle follows output and clears on reflow" { try std.testing.expect(pane.pointerSelection(pane.sel[0]) == null); try std.testing.expectEqual(.none, pane.sel[0].state); } + +test "mouse thumb buttons navigate once without disturbing selection gestures" { + const p = try Pardes.init(std.testing.allocator, .{ .cols = 60, .rows = 12 }); + defer p.deinit(); + const pane = try p.setTestFile("alpha beta\ngamma delta\nepsilon zeta\n"); + pane.setRange(pane.file.?.content, 0, .{ .anchor = 1, .head = 5 }, true); + const selection = pane.primaryRange(pane.file.?.content, 0); + p.njumps = 3; + p.jcur = 2; + for (0..3) |i| p.jumps[i] = .{ .pane = 0, .serial = pane.serial, .line = @intCast(i + 1), .col = 1 }; + p.drag = .{ .select = .{ .id = 0, .button = config.select_button } }; + const drag = p.drag; + p.update(.{ .mouse = .{ .button = .back, .kind = .press, .col = 0, .row = 0 } }); + try std.testing.expectEqual(@as(usize, 1), p.jcur); + for ([_]Mouse.Kind{ .drag, .release, .motion }) |kind| + p.update(.{ .mouse = .{ .button = .back, .kind = kind, .col = 0, .row = 0 } }); + try std.testing.expectEqual(@as(usize, 1), p.jcur); + p.update(.{ .mouse = .{ .button = .forward, .kind = .press, .col = 0, .row = 0 } }); + p.update(.{ .mouse = .{ .button = .forward, .kind = .release, .col = 0, .row = 0 } }); + try std.testing.expectEqual(@as(usize, 2), p.jcur); + try std.testing.expectEqualDeep(selection, pane.primaryRange(pane.file.?.content, 0)); + try std.testing.expectEqualDeep(drag, p.drag); +} diff --git a/src/web.zig b/src/web.zig index 3a9446b6..bceaaeeb 100644 --- a/src/web.zig +++ b/src/web.zig @@ -217,6 +217,8 @@ export fn pardes_mouse(button_arg: u32, kind_arg: u32, col_arg: u32, row_arg: u3 5 => .wheel_left, 6 => .wheel_right, 7 => .none, + 8 => .back, + 9 => .forward, else => return, }; const kind: pardes.Mouse.Kind = switch (kind_arg) { diff --git a/src/web/app.mjs b/src/web/app.mjs index fcb9c23d..be61de89 100644 --- a/src/web/app.mjs +++ b/src/web/app.mjs @@ -7,7 +7,7 @@ const DEFAULT_BG = "#121212"; const decoder = new TextDecoder(); const encoder = new TextEncoder(); -const BUTTON = Object.freeze({ left: 0, middle: 1, right: 2, wheelUp: 3, wheelDown: 4, wheelLeft: 5, wheelRight: 6, none: 7 }); +const BUTTON = Object.freeze({ left: 0, middle: 1, right: 2, wheelUp: 3, wheelDown: 4, wheelLeft: 5, wheelRight: 6, none: 7, back: 8, forward: 9 }); const MOUSE = Object.freeze({ press: 0, release: 1, motion: 2, drag: 3 }); const SPECIAL_KEYS = Object.freeze({ Enter: 0x0d, @@ -214,11 +214,22 @@ export class PardesRuntime { return { x: rect.left + (col + 0.5) * this.cellWidth, y: rect.top + (row + 0.5) * this.cellHeight }; } - pointerDown(event) { + mouseDown(event) { event.preventDefault(); + if (event.button !== 3 && event.button !== 4) return; + this.root.focus({ preventScroll: true }); + const point = this.point(event); + this.wasm.pardes_mouse(pointerButton(event.button), MOUSE.press, point.col, point.row, modifiers(event)); + } + + pointerDown(event) { + // Mouse defaults are cancelled by mousedown. Cancelling pointerdown + // would suppress that stream, including thumb presses while left is held. + if (event.button === 3 || event.button === 4) return; + if (event.pointerType === "touch") event.preventDefault(); this.root.focus({ preventScroll: true }); - this.root.setPointerCapture?.(event.pointerId); const point = this.point(event); + this.root.setPointerCapture?.(event.pointerId); if (event.pointerType === "touch") { const packed = this.wasm.pardes_chrome_target(point.col, point.row) >>> 0; const chrome = packed !== 0xffffffff; @@ -233,6 +244,7 @@ export class PardesRuntime { } pointerMove(event) { + if (event.button === 3 || event.button === 4) return; const point = this.point(event); const active = this.pointer?.id === event.pointerId ? this.pointer : null; if (!active) { @@ -268,6 +280,10 @@ export class PardesRuntime { } pointerUp(event) { + if (event.button === 3 || event.button === 4) { + event.preventDefault(); + return; + } const active = this.pointer?.id === event.pointerId ? this.pointer : null; if (!active) return; event.preventDefault(); @@ -303,6 +319,9 @@ export class PardesRuntime { this.root.addEventListener("keydown", (event) => this.key(event)); this.root.addEventListener("paste", (event) => this.paste(event)); this.root.addEventListener("contextmenu", (event) => event.preventDefault()); + this.root.addEventListener("auxclick", (event) => event.preventDefault()); + // Mouse events retain additional button presses while another button is held. + this.root.addEventListener("mousedown", (event) => this.mouseDown(event)); this.root.addEventListener("pointerdown", (event) => this.pointerDown(event)); this.root.addEventListener("pointermove", (event) => this.pointerMove(event)); this.root.addEventListener("pointerup", (event) => this.pointerUp(event)); @@ -453,7 +472,7 @@ function modifiers(event) { } function pointerButton(button) { - return button === 1 ? BUTTON.middle : button === 2 ? BUTTON.right : BUTTON.left; + return button === 1 ? BUTTON.middle : button === 2 ? BUTTON.right : button === 3 ? BUTTON.back : button === 4 ? BUTTON.forward : BUTTON.left; } function clamp(value, low, high) { diff --git a/test/web-snapshots/touch.mjs b/test/web-snapshots/touch.mjs index 1a233e68..8c34b185 100644 --- a/test/web-snapshots/touch.mjs +++ b/test/web-snapshots/touch.mjs @@ -1,7 +1,8 @@ import assert from "node:assert/strict"; function tags(frame) { - return frame.styleRows.flatMap((cells, y) => y > 0 && cells.some((cell) => cell[2] & 128) ? [y] : []); + // Pane tags have a gutter; the workspace and column bars do not. + return frame.styleRows.flatMap((cells, y) => /^\s{2}\S/.test(frame.lines[y]) && cells.some((cell) => cell[2] & 128) ? [y] : []); } function pane(frame, name) { @@ -15,9 +16,9 @@ function pane(frame, name) { function styled(frame) { const source = pane(frame, "touch-fixture.zig"); for (const [line, word, fg, bold] of [ - ["pub fn choose", "fn", 0xcf87e8, true], + ["pub fn choose", "fn", 0xcf87e8, false], ["pub fn choose", "choose", 0xbdbdbd, false], - ["if (value", "if", 0xcf87e8, true], + ["if (value", "if", 0xcf87e8, false], ["if (value", "42", 0xe3c78a, false], ["if (value", '"touch fixture"', 0xe3c78a, false], ]) { @@ -39,7 +40,7 @@ function styled(frame) { } } -export default async function ({ grid, touch, wait, stable, delay }) { +export default async function ({ grid, touch, wait, stable, delay, evaluate, mouse }) { await stable(100, 5000); let frame = await grid(); assert.equal(tags(frame).length, 1); @@ -96,4 +97,73 @@ export default async function ({ grid, touch, wait, stable, delay }) { assert.notEqual(scrolled.lines[0], beforeScroll, "source must scroll"); assert(Number(scrolled.lines[0].trim().split(/\s+/)[0]) > 1, "scroll advances source line numbers"); assert.deepEqual(pane(frame, link).lines, reflectedBeforeScroll, "scrolling the source must leave its neighbor alone"); + + const sourceRow = scrolled.top + 2; + const reflectedRow = pane(frame, link).top + 2; + const point = await evaluate(`(() => { + const runtime = pardes.runtime; + const root = runtime.root; + const wasm = runtime.wasm; + const events = []; + const canceled = []; + const pointerDownCanceled = []; + const click = (row) => { + wasm.pardes_mouse(0, 0, 10, row, 0); + wasm.pardes_mouse(0, 1, 10, row, 0); + wasm.pardes_tick(); + runtime.renderer.render(); + }; + click(${sourceRow}); + click(${reflectedRow}); + runtime.wasm = { ...wasm, pardes_mouse: (...args) => { + events.push(args.slice(0, 2)); + wasm.pardes_mouse(...args); + } }; + const observe = (event) => { + if (event.type === "pointerdown") pointerDownCanceled.push(event.defaultPrevented); + else if (event.button === 3 || event.button === 4) canceled.push(event.defaultPrevented); + }; + for (const type of ["pointerdown", "mousedown", "auxclick"]) root.addEventListener(type, observe); + pardes.thumbTest = { wasm, observe, events, canceled, pointerDownCanceled }; + return runtime.cellPoint(10, ${sourceRow}); + })()`); + const thumb = async (button, held = false) => { + const mask = button === "back" ? 8 : 16; + await mouse({ type: "mousePressed", button, buttons: mask | Number(held), ...point, clickCount: 1 }); + await mouse({ type: "mouseReleased", button, buttons: Number(held), ...point, clickCount: 1 }); + await stable(100, 5000); + return (await grid()).cursorY; + }; + try { + const backRow = await thumb("back"); + const forwardRow = await thumb("forward"); + assert(backRow > scrolled.top && backRow < scrolled.bottom, "Back returns to the source pane"); + const reflectedAfter = pane(await grid(), link); + assert(forwardRow > reflectedAfter.top && forwardRow < reflectedAfter.bottom, "Forward returns to the reflected pane"); + + await mouse({ type: "mousePressed", button: "left", buttons: 1, ...point, clickCount: 1 }); + await evaluate("pardes.thumbTest.heldPointer = pardes.runtime.pointer"); + await thumb("back", true); + await thumb("forward", true); + const navigation = await evaluate(`({ + events: pardes.thumbTest.events, + canceled: pardes.thumbTest.canceled, + pointerDownCanceled: pardes.thumbTest.pointerDownCanceled, + keptPointer: pardes.thumbTest.heldPointer !== null && pardes.runtime.pointer === pardes.thumbTest.heldPointer, + })`); + const presses = navigation.events.filter((event) => event[1] === 0 && (event[0] === 8 || event[0] === 9)); + assert.deepEqual(presses, [[8, 0], [9, 0], [8, 0], [9, 0]], "each physical thumb press navigates once"); + assert(!navigation.events.some((event) => event[0] === 1 || (event[0] === 0 && event[1] === 3)), "thumb buttons must not become selection drags or middle clicks"); + assert(navigation.canceled.length >= 4 && navigation.canceled.every(Boolean), "thumb mouse and auxclick events must cancel browser defaults"); + assert(navigation.pointerDownCanceled.every((value) => !value), "mouse pointerdown must permit subsequent mousedown events"); + assert(navigation.keptPointer, "thumb navigation must retain the held selection pointer"); + } finally { + await mouse({ type: "mouseReleased", button: "left", buttons: 0, ...point, clickCount: 1 }); + await evaluate(`(() => { + for (const type of ["pointerdown", "mousedown", "auxclick"]) + pardes.runtime.root.removeEventListener(type, pardes.thumbTest.observe); + pardes.runtime.wasm = pardes.thumbTest.wasm; + delete pardes.thumbTest; + })()`); + } } diff --git a/test/web_snapshot.mjs b/test/web_snapshot.mjs index a9cd8dff..61467c4d 100644 --- a/test/web_snapshot.mjs +++ b/test/web_snapshot.mjs @@ -82,7 +82,8 @@ async function main() { if (scenarioModule) { const scenario = await import(pathToFileURL(scriptPath)); - await scenario.default({ grid, touch: dispatchTouch, wait: waitUntil, stable: waitStable, delay }); + await scenario.default({ grid, touch: dispatchTouch, wait: waitUntil, stable: waitStable, delay, evaluate, + mouse: (event) => client.send("Input.dispatchMouseEvent", event) }); console.error(`web test ok: ${path.relative(process.cwd(), scriptPath)}`); return; } -- cgit v1.3