summaryrefslogtreecommitdiff
path: root/src/mouse.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-30 14:12:51 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:17 -0300
commitb546b578baadbd8fdd15b42988ad4d66c9c8ff21 (patch)
treebbbcaa94d63b0b695f09f2d389ca287377880d9a /src/mouse.zig
parent3f6733c805d873a7af6a6064ace0cf5916f481da (diff)
downloadpardes-b546b578baadbd8fdd15b42988ad4d66c9c8ff21.tar.gz
pardes-b546b578baadbd8fdd15b42988ad4d66c9c8ff21.zip
A program that tracks the mouse gets its clicks, drags and moves over its grid, in the format it chose; Shift keeps pardes's select, execute and look, and chrome is never the program's
Presses and releases under 1000, drags with a button down under 1002, every move once per cell under 1003, X10 presses under 9, in raw terminal input only. A press the program took keeps its drag and release, reported clamped to the grid's edge wherever the pointer goes. The press also makes the pane the active one. docs/tags.md says so. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Diffstat (limited to 'src/mouse.zig')
-rw-r--r--src/mouse.zig203
1 files changed, 193 insertions, 10 deletions
diff --git a/src/mouse.zig b/src/mouse.zig
index e80ea7f8..ecb9bb57 100644
--- a/src/mouse.zig
+++ b/src/mouse.zig
@@ -335,6 +335,104 @@ fn programWheel(p: *Pardes, id: usize, m: Mouse, col: u16, row: u16) bool {
return true;
}
+pub const Grab = struct { id: usize, serial: u32, held: u3 = 0 };
+
+/// The 0-based cell of a terminal program's grid under screen cell
+/// `col`,`row`, or null over chrome (tag, gutter, scrollbar, anything past
+/// the grid).
+fn programCell(p: *const Pardes, id: usize, col: u16, row: u16) ?struct { col: i32, row: i32 } {
+ const pane = p.panes[id] orelse return null;
+ const r = p.rects[id];
+ const x0 = r.x + config.GUTTER;
+ const y0 = p.bodyTop(pane, r);
+ const h = @min(r.h -| pane.tag_rows, pane.rows);
+ if (col < x0 or col >= r.x + r.w or col - x0 >= pane.cols or row < y0 or row - y0 >= h) return null;
+ return .{ .col = col - x0, .row = row - y0 };
+}
+
+/// A pane whose program tracks the mouse (1000, 1002, 1003, or X10's 9)
+/// and has the keyboard's raw input: its clicks are the program's.
+fn tracksMouse(pane: *const Pane) bool {
+ return pane.isTerminal() and pane.body.mode == .tty and panes.terminal.reportsMouse(pane);
+}
+
+/// A program that tracks the mouse gets it over its grid, as xterm gives
+/// it: presses, releases, drags with a button down (1002) and, under 1003,
+/// every move, once per cell. A press it took keeps its drag and release
+/// wherever the pointer goes. Tags, gutters and the rest of pardes's
+/// chrome are never its, and Shift keeps pardes's own select, execute and
+/// look. True when the event was the program's.
+fn programMouse(p: *Pardes, m: Mouse) bool {
+ const button: ?panes.terminal.MouseButton = switch (m.button) {
+ .left => .left,
+ .middle => .middle,
+ .right => .right,
+ .none => null,
+ else => return false,
+ };
+ const bit: u3 = if (button) |b| @as(u3, 1) << @intCast(@intFromEnum(b)) else 0;
+ const at = p.presentation.pointer(p.screen_w, p.screen_h, @min(m.col, p.screen_w -| 1), @min(m.row, p.screen_h -| 1));
+ const mods: panes.terminal.MouseMods = .{ .shift = m.shift, .alt = m.alt, .ctrl = m.ctrl };
+ var buf: [64]u8 = undefined;
+ if (p.mouse_grab) |*grab| {
+ const pane = p.panes[grab.id] orelse {
+ p.mouse_grab = null;
+ return false;
+ };
+ if (pane.serial != grab.serial or !pane.isTerminal()) {
+ p.mouse_grab = null;
+ return false;
+ }
+ // Off the grid the cell runs on past its edge, and the encoder
+ // clamps it there.
+ const r = p.rects[grab.id];
+ const col: i32 = if (at) |a| @as(i32, a.col) - @as(i32, r.x + config.GUTTER) else 0;
+ const row: i32 = if (at) |a| @as(i32, a.row) - @as(i32, p.bodyTop(pane, r)) else 0;
+ const action: panes.terminal.MouseAction = switch (m.kind) {
+ .press => .press,
+ .release => .release,
+ .drag, .motion => .motion,
+ };
+ if (m.kind == .press) grab.held |= bit;
+ if (m.kind == .release) grab.held &= ~bit;
+ const bytes = panes.terminal.mouseBytes(pane, &buf, action, button, mods, col, row, grab.held != 0);
+ if (bytes.len > 0) p.emitWrite(grab.id, bytes);
+ if (grab.held == 0) p.mouse_grab = null;
+ return true;
+ }
+ if (m.shift or p.drag != .none or p.header_drag or p.header_sweep != null) return false;
+ const mapped = at orelse return false;
+ const id = paneAt(p, mapped.col, mapped.row) orelse return false;
+ const pane = p.panes[id].?;
+ if (!tracksMouse(pane)) return false;
+ const cell = programCell(p, id, mapped.col, mapped.row) orelse return false;
+ switch (m.kind) {
+ .press => {
+ if (id != p.active) if (p.panes[p.active]) |previous| {
+ previous.focus = .body;
+ previous.closePrompt();
+ };
+ p.active = id;
+ pane.focus = .body;
+ pane.closePrompt();
+ p.mouse_grab = .{ .id = id, .serial = pane.serial, .held = bit };
+ const bytes = panes.terminal.mouseBytes(pane, &buf, .press, button, mods, cell.col, cell.row, true);
+ if (bytes.len > 0) p.emitWrite(id, bytes);
+ return true;
+ },
+ // A release or drag of a press pardes never passed on stays
+ // pardes's.
+ .release, .drag => return false,
+ .motion => {
+ const bytes = panes.terminal.mouseBytes(pane, &buf, .motion, null, mods, cell.col, cell.row, false);
+ if (bytes.len > 0) p.emitWrite(id, bytes);
+ // Under 1003 the move is the program's; below that pardes
+ // hovers as ever.
+ return panes.terminal.tracksAllMotion(pane);
+ },
+ }
+}
+
pub fn handleMouse(p: *Pardes, m: Mouse) void {
// A click goes somewhere else: the question it leaves unanswered is
// taken back, and the pane stays. A wheel only looks around.
@@ -384,6 +482,7 @@ pub fn handleMouse(p: *Pardes, m: Mouse) void {
}
}
p.pointer_inside = true;
+ if (programMouse(p, m)) return;
if (m.button == .none and m.kind == .motion) {
p.raw_hover_intent = true;
look.refreshLookHoverFromRaw(p);
@@ -1258,16 +1357,13 @@ fn chordCutPaste(p: *Pardes, cut: bool) void {
const y = p.registers.text(p.gpa, pardes.Registers.default) orelse return;
if (y.len == 0) return;
if (panes.terminal.reportsMouse(pane)) {
- const col: u16 = @intCast(std.math.clamp(pane.sel[sel_slot].c1 + 1, 1, 9999));
- const row: u16 = @intCast(std.math.clamp(pane.sel[sel_slot].r1 - @as(i32, pane.tag_rows) + 1, 1, 9999));
- var mb: [32]u8 = undefined;
- if (panes.terminal.mouseFormatSgr(pane)) {
- p.emitWrite(s.id, std.fmt.bufPrint(&mb, "\x1b[<0;{d};{d}M\x1b[<0;{d};{d}m", .{ col, row, col, row }) catch return);
- } else {
- // ponytail: legacy X10 bytes; add utf8/urxvt formats if an app ever wants them
- const cb: u8 = @intCast(32 + @min(col, 222));
- const rb: u8 = @intCast(32 + @min(row, 222));
- p.emitWrite(s.id, &[_]u8{ 0x1b, '[', 'M', 32, cb, rb, 0x1b, '[', 'M', 35, cb, rb });
+ // A click where the text goes, so the program's cursor is there.
+ const col = @max(0, pane.sel[sel_slot].c1);
+ const row = @max(0, pane.sel[sel_slot].r1 - @as(i32, pane.tag_rows));
+ var mb: [64]u8 = undefined;
+ for ([_]panes.terminal.MouseAction{ .press, .release }) |action| {
+ const bytes = panes.terminal.mouseBytes(pane, &mb, action, .left, .{}, col, row, action == .press);
+ if (bytes.len > 0) p.emitWrite(s.id, bytes);
}
}
edit.typeToTty(p, s.id, pane, y);
@@ -1897,3 +1993,90 @@ test "a wheel over a program that owns it is the program's, in the form it asked
p.update(.{ .mouse = .{ .button = .wheel_up, .kind = .press, .col = col, .row = row } });
try std.testing.expectEqualStrings("\x1b[<64;6;4M", try ptyWrites(p, &out));
}
+
+test "a program tracking the mouse gets its clicks, drags and moves over its grid as xterm reports them; Shift and chrome stay pardes's" {
+ if (comptime !panes.terminal.enabled) return error.SkipZigTest;
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .cols = 60, .rows = 16, .tty_only = true });
+ defer p.deinit();
+ var frame = std.heap.ArenaAllocator.init(gpa);
+ defer frame.deinit();
+ _ = try p.render(frame.allocator());
+ var out: std.ArrayList(u8) = .empty;
+ defer out.deinit(gpa);
+ _ = try ptyWrites(p, &out);
+ const pane = p.panes[0].?;
+ try std.testing.expectEqual(.tty, pane.body.mode);
+ const r = p.rects[0];
+ const col = r.x + config.GUTTER + 5;
+ const row = p.bodyTop(pane, r) + 3;
+ const Ev = struct { button: Mouse.Button, kind: Mouse.Kind, dx: i32 = 0, dy: i32 = 0, shift: bool = false, alt: bool = false, ctrl: bool = false };
+ const send = struct {
+ fn f(pp: *Pardes, o: *std.ArrayList(u8), c: u16, rw: u16, e: Ev) ![]const u8 {
+ pp.update(.{ .mouse = .{
+ .button = e.button,
+ .kind = e.kind,
+ .col = @intCast(@as(i32, c) + e.dx),
+ .row = @intCast(@as(i32, rw) + e.dy),
+ .shift = e.shift,
+ .alt = e.alt,
+ .ctrl = e.ctrl,
+ } });
+ return ptyWrites(pp, o);
+ }
+ }.f;
+ // htop's modes: clicks in SGR, a release its own `m`.
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?1049h\x1b[?1h\x1b[?1006;1000h" } });
+ _ = try ptyWrites(p, &out);
+ try std.testing.expectEqualStrings("\x1b[<0;6;4M", try send(p, &out, col, row, .{ .button = .left, .kind = .press }));
+ try std.testing.expect(p.drag == .none);
+ // 1000 has no drags; the release is reported where it lands.
+ try std.testing.expectEqualStrings("", try send(p, &out, col, row, .{ .button = .left, .kind = .drag, .dx = 1 }));
+ try std.testing.expectEqualStrings("\x1b[<0;7;4m", try send(p, &out, col, row, .{ .button = .left, .kind = .release, .dx = 1 }));
+ // Right with meta and ctrl: 2 + 8 + 16.
+ try std.testing.expectEqualStrings("\x1b[<26;6;4M", try send(p, &out, col, row, .{ .button = .right, .kind = .press, .alt = true, .ctrl = true }));
+ try std.testing.expectEqualStrings("\x1b[<2;6;4m", try send(p, &out, col, row, .{ .button = .right, .kind = .release }));
+ // Moves are no 1000 program's business: pardes hovers.
+ try std.testing.expectEqualStrings("", try send(p, &out, col, row, .{ .button = .none, .kind = .motion }));
+ // Shift: pardes's own sweep, the program hears nothing of it.
+ try std.testing.expectEqualStrings("", try send(p, &out, col, row, .{ .button = .left, .kind = .press, .shift = true }));
+ try std.testing.expect(p.drag == .select);
+ try std.testing.expectEqualStrings("", try send(p, &out, col, row, .{ .button = .left, .kind = .drag, .dx = 2 }));
+ try std.testing.expectEqualStrings("", try send(p, &out, col, row, .{ .button = .left, .kind = .release, .dx = 2 }));
+ try std.testing.expect(p.drag == .none);
+ // Chrome is never the program's: the tag, and the gutter.
+ const tag_row = p.tagTop(pane, r);
+ try std.testing.expectEqualStrings("", try send(p, &out, col, tag_row, .{ .button = .right, .kind = .press }));
+ try std.testing.expectEqualStrings("", try send(p, &out, col, tag_row, .{ .button = .right, .kind = .release }));
+ try std.testing.expectEqualStrings("", try send(p, &out, r.x, row, .{ .button = .left, .kind = .press }));
+ try std.testing.expectEqualStrings("", try send(p, &out, r.x, row, .{ .button = .left, .kind = .release }));
+
+ // codex's any-event tracking: a drag once per cell, carried past the
+ // grid's edge and clamped there, and bare moves.
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?1003h" } });
+ _ = try ptyWrites(p, &out);
+ try std.testing.expectEqualStrings("\x1b[<35;9;4M", try send(p, &out, col, row, .{ .button = .none, .kind = .motion, .dx = 3 }));
+ try std.testing.expectEqualStrings("", try send(p, &out, col, row, .{ .button = .none, .kind = .motion, .dx = 3 }));
+ try std.testing.expectEqualStrings("\x1b[<0;6;4M", try send(p, &out, col, row, .{ .button = .left, .kind = .press }));
+ try std.testing.expectEqualStrings("\x1b[<32;7;4M", try send(p, &out, col, row, .{ .button = .left, .kind = .drag, .dx = 1 }));
+ try std.testing.expectEqualStrings("", try send(p, &out, col, row, .{ .button = .left, .kind = .drag, .dx = 1 }));
+ // Up over the tag: row 1, the grid's first.
+ try std.testing.expectEqualStrings("\x1b[<32;7;1M", try send(p, &out, col + 1, tag_row, .{ .button = .left, .kind = .drag }));
+ try std.testing.expectEqualStrings("\x1b[<0;7;1m", try send(p, &out, col + 1, tag_row, .{ .button = .left, .kind = .release }));
+ try std.testing.expect(p.mouse_grab == null);
+ // Over the tag a bare move is pardes's.
+ try std.testing.expectEqualStrings("", try send(p, &out, col, tag_row, .{ .button = .none, .kind = .motion }));
+
+ // Legacy X10 bytes: a release is button 3. (Turning 1003 off turns
+ // tracking off, as xterm's one tracking mode goes.)
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?1003l\x1b[?1006l\x1b[?1000h" } });
+ _ = try ptyWrites(p, &out);
+ try std.testing.expectEqualStrings("\x1b[M &$", try send(p, &out, col, row, .{ .button = .left, .kind = .press }));
+ try std.testing.expectEqualStrings("\x1b[M#&$", try send(p, &out, col, row, .{ .button = .left, .kind = .release }));
+
+ // A pane out of the program's raw input (Esc to normal mode) clicks as
+ // pardes clicks.
+ pane.body.mode = .normal;
+ try std.testing.expectEqualStrings("", try send(p, &out, col, row, .{ .button = .left, .kind = .press }));
+ try std.testing.expectEqualStrings("", try send(p, &out, col, row, .{ .button = .left, .kind = .release }));
+}