summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/helix-keys.md2
-rw-r--r--src/detached/wire.zig2
-rw-r--r--src/gui/gui.zig2
-rw-r--r--src/macos.zig4
-rw-r--r--src/macos/Sources/PardesView.swift24
-rw-r--r--src/macos/pardes.h2
-rw-r--r--src/pardes.zig31
-rw-r--r--src/web.zig2
-rw-r--r--src/web/app.mjs27
-rw-r--r--test/web-snapshots/touch.mjs78
-rw-r--r--test/web_snapshot.mjs3
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;
}