summaryrefslogtreecommitdiff
path: root/src/mouse.zig
diff options
context:
space:
mode:
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);
+}