summaryrefslogtreecommitdiff
path: root/src/mouse.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-30 13:49:09 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:17 -0300
commit387d6a39bad76c5eb2f56328f7cf7869a9a10559 (patch)
treecf7aa5209e1d0c9dc522833239b2a710ca1351d9 /src/mouse.zig
parent3921bfe63980552eddeaa930d5d921d9a2868b37 (diff)
downloadpardes-387d6a39bad76c5eb2f56328f7cf7869a9a10559.tar.gz
pardes-387d6a39bad76c5eb2f56328f7cf7869a9a10559.zip
A wheel over a terminal whose program owns it goes to the program: its mouse report when it tracks the mouse, arrow keys on the alternate screen with alternate scroll, so codex, htop and less scroll
codex 0.158 moved its chat onto the alternate screen with any-event SGR mouse tracking (1049, 1000/1002/1003/1006, 1007 off); its sign-in screen keeps 1007 on with tracking off. pardes never forwarded a wheel to a program, only scrolled ghostty's scrollback, which the alternate screen does not have, so the wheel did nothing there. A shell on the main screen still scrolls its scrollback as before. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Diffstat (limited to 'src/mouse.zig')
-rw-r--r--src/mouse.zig85
1 files changed, 83 insertions, 2 deletions
diff --git a/src/mouse.zig b/src/mouse.zig
index 2280020d..a0ec6e47 100644
--- a/src/mouse.zig
+++ b/src/mouse.zig
@@ -310,6 +310,23 @@ pub fn sameBodyCell(a: ?Mouse.BodyHit, b: ?Mouse.BodyHit) bool {
return first.serial == second.serial and first.row == second.row and first.col == second.col;
}
+/// A wheel over a terminal whose program owns it (`terminal.ownsWheel`:
+/// codex, htop, less) goes to the program as the program asked, one turn
+/// per line, instead of scrolling a scrollback the alternate screen does
+/// not have. False when pardes scrolls.
+fn programWheel(p: *Pardes, id: usize, wheel: panes.terminal.Wheel, col: u16, row: u16, ctrl: bool) bool {
+ const pane = p.panes[id] orelse return false;
+ if (!pane.isTerminal() or !panes.terminal.ownsWheel(pane)) return false;
+ const r = p.rects[id];
+ const body_y = p.bodyTop(pane, r);
+ const gcol = std.math.clamp(col -| (r.x + config.GUTTER), 0, pane.cols -| 1);
+ const grow = std.math.clamp(row -| body_y, 0, pane.rows -| 1);
+ var buf: [32]u8 = undefined;
+ const bytes = panes.terminal.wheelBytes(pane, &buf, wheel, gcol, grow, ctrl) orelse return true;
+ for (0..@intCast(config.wheel_rows)) |_| p.emitWrite(id, bytes);
+ return true;
+}
+
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.
@@ -508,7 +525,7 @@ pub fn handleMouse(p: *Pardes, m: Mouse) void {
const pane = p.panes[id].?;
if (pane.hasPdf())
panes.Pdf.verticalWheel(p, pane, if (m.button == .wheel_up) -1 else 1)
- else
+ else if (!programWheel(p, id, if (m.button == .wheel_up) .up else .down, mcol, mrow, m.ctrl))
pane.scrollBy(if (m.button == .wheel_up) -config.wheel_rows else config.wheel_rows);
},
.wheel_left, .wheel_right => {
@@ -516,7 +533,9 @@ pub fn handleMouse(p: *Pardes, m: Mouse) void {
if (!config.wheelTick(&p.wheel_guard, false)) return;
const id = hovered orelse return;
const pane = p.panes[id].?;
- if (pane.hasPdf()) {
+ if (programWheel(p, id, if (m.button == .wheel_right) .right else .left, mcol, mrow, m.ctrl)) {
+ // the program's
+ } else if (pane.hasPdf()) {
panes.Pdf.horizontalWheel(p, pane, if (m.button == .wheel_right) 1 else -1);
// ponytail: no right clamp — overscroll shows blank and the
// next cursor move or left wheel pulls it back
@@ -1793,3 +1812,65 @@ test "a chorded exec an event reader holds is told with its argument, and writte
}
_ = th.call(p, .{ .tag = 4, .op = .release, .node = event, .handle = h });
}
+
+/// Every byte the core wrote to pane 0's pty since the last call.
+fn ptyWrites(p: *Pardes, out: *std.ArrayList(u8)) ![]const u8 {
+ out.clearRetainingCapacity();
+ while (p.nextEffect()) |effect| {
+ if (effect == .write and effect.write.pane == 0) try out.appendSlice(p.gpa, effect.write.bytes.slice());
+ }
+ return out.items;
+}
+
+test "a wheel over a program that owns it is the program's, in the form it asked for; a shell's scrolls" {
+ 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].?;
+ const r = p.rects[0];
+ // Grid cell 6,4 (1-based), as a program counts.
+ const col = r.x + config.GUTTER + 5;
+ const row = p.bodyTop(pane, r) + 3;
+ const Case = struct { modes: []const u8, button: Mouse.Button, ctrl: bool = false, sent: []const u8 };
+ const cases = [_]Case{
+ // codex's chat (0.158+): the alternate screen, any-event tracking, SGR.
+ .{ .modes = "\x1b[?2004h\x1b[>7u\x1b[?1004h\x1b[?1049h\x1b[?1007l\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1006h", .button = .wheel_up, .sent = "\x1b[<64;6;4M" },
+ .{ .modes = "", .button = .wheel_down, .ctrl = true, .sent = "\x1b[<81;6;4M" },
+ // htop's legacy bytes (1000 alone), and 1015's.
+ .{ .modes = "\x1b[?1006l", .button = .wheel_down, .sent = "\x1b[M\x61\x26\x24" },
+ .{ .modes = "\x1b[?1015h", .button = .wheel_up, .sent = "\x1b[96;6;4M" },
+ // codex's sign-in, less: the alternate screen asking for arrow keys
+ // (1007), in the cursor keys' mode.
+ .{ .modes = "\x1b[?1015l\x1b[?1003l\x1b[?1002l\x1b[?1000l\x1b[?1007h", .button = .wheel_down, .sent = "\x1b[B" },
+ .{ .modes = "\x1b[?1h", .button = .wheel_up, .sent = "\x1bOA" },
+ .{ .modes = "", .button = .wheel_right, .sent = "" },
+ // With alternate scroll off the alternate screen keeps the wheel.
+ .{ .modes = "\x1b[?1007l", .button = .wheel_up, .sent = "" },
+ };
+ for (cases) |case| {
+ p.update(.{ .output = .{ .pane = 0, .bytes = case.modes } });
+ _ = try ptyWrites(p, &out);
+ p.update(.{ .mouse = .{ .button = case.button, .kind = .press, .col = col, .row = row, .ctrl = case.ctrl } });
+ try std.testing.expectEqualStrings(case.sent, try ptyWrites(p, &out));
+ }
+ // A shell on the main screen: the wheel scrolls its scrollback, and
+ // the shell hears nothing.
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?1l\x1b[?1049l" } });
+ for (0..40) |i| {
+ var line: [16]u8 = undefined;
+ p.update(.{ .output = .{ .pane = 0, .bytes = try std.fmt.bufPrint(&line, "{d}\r\n", .{i}) } });
+ }
+ _ = try p.render(frame.allocator());
+ _ = try ptyWrites(p, &out);
+ const was = pane.scroll();
+ p.update(.{ .mouse = .{ .button = .wheel_up, .kind = .press, .col = col, .row = row } });
+ try std.testing.expectEqualStrings("", try ptyWrites(p, &out));
+ try std.testing.expect(pane.scroll() != was);
+}