diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-30 13:49:09 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 00:12:17 -0300 |
| commit | 387d6a39bad76c5eb2f56328f7cf7869a9a10559 (patch) | |
| tree | cf7aa5209e1d0c9dc522833239b2a710ca1351d9 | |
| parent | 3921bfe63980552eddeaa930d5d921d9a2868b37 (diff) | |
| download | pardes-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]>
| -rw-r--r-- | src/gui/gui.zig | 183 | ||||
| -rw-r--r-- | src/mouse.zig | 85 | ||||
| -rw-r--r-- | src/terminal.zig | 49 |
3 files changed, 266 insertions, 51 deletions
diff --git a/src/gui/gui.zig b/src/gui/gui.zig index be961477..045f22d9 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -1882,6 +1882,9 @@ const Gui = struct { /// G6: a wheel's notches, whole lines, scroll the core at once and the /// picture follows on a spring (Glide), never the other way round. scroll_notch: i32 = 0, + /// A touchpad's fraction toward the next turn sent to a program that + /// owns the wheel (terminal.ownsWheel). + program_wheel: f32 = 0, glide: Glide = .{}, /// G9 and G11: the selection's and the hovered word's glow blocks as /// last drawn, for their fade-in. @@ -3578,55 +3581,7 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void { }; g.pointer_mapped = true; g.pointer_cell = mc; - if (w.y != 0 and std.math.isFinite(w.y)) { - if (in.core) |core| { - const hit: ?usize = for (core.panes, 0..) |slot, i| { - if (slot == null) continue; - const r = core.rects[i]; - if (mc.col >= r.x and mc.col < r.x + r.w and mc.row >= r.y and mc.row < r.y + r.h) break i; - } else null; - if (g.scroll_pane) |old| if (hit == null or hit.? != old) { - resetScroll(g); - }; - if (hit) |id| { - const pdf_target = if (comptime pardes.pdf_enabled) - core.native_images and core.panes[id].?.pdfPage() != null - else - false; - if (pdf_target) { - resetScroll(g); - in.post(.{ .pdf_scroll = .{ - .pane = @intCast(id), - .delta_pixels = -w.y * @as(f32, @floatFromInt(g.cell_h)), - } }); - } else { - g.scroll_pane = id; - g.scroll_col = mc.col; - g.scroll_row = mc.row; - // A mouse wheel's notch is a whole line and - // glides (G6); a touchpad's fraction moves the - // picture by itself, under the finger. - if (notchOf(w.y, w.integer_y)) |lines| { - g.scroll_notch +|= lines; - } else g.scroll_delta = accumulateWheelDelta(g.scroll_delta, w.y); - } - } - } else { - g.scroll_delta = accumulateWheelDelta(g.scroll_delta, w.y); - while (g.scroll_delta >= 1) : (g.scroll_delta -= 1) - in.post(.{ .mouse = .{ .button = .wheel_down, .kind = .press, .col = mc.col, .row = mc.row, .tag_hit = mc.tag_hit, .body_hit = mc.body_hit } }); - while (g.scroll_delta <= -1) : (g.scroll_delta += 1) - in.post(.{ .mouse = .{ .button = .wheel_up, .kind = .press, .col = mc.col, .row = mc.row, .tag_hit = mc.tag_hit, .body_hit = mc.body_hit } }); - } - } - if (w.x != 0) in.post(.{ .mouse = .{ - .button = if (w.x > 0) .wheel_right else .wheel_left, - .kind = .press, - .col = mc.col, - .row = mc.row, - .tag_hit = mc.tag_hit, - .body_hit = mc.body_hit, - } }); + wheelAt(g, in, .{ .x = w.x, .y = w.y, .integer_y = w.integer_y }, mc); }, c.SDL_EVENT_FINGER_DOWN, c.SDL_EVENT_FINGER_MOTION, c.SDL_EVENT_FINGER_UP, c.SDL_EVENT_FINGER_CANCELED => { const f = sev.tfinger; @@ -3933,6 +3888,73 @@ test "a click at the window's corner lands in the corner cell with a post chain /// Where a window point lands: the cell drawn under it. Input is identity: /// no post pass moves a pixel (the bundled Crt has no barrel), so a click /// is exactly where it looks. +/// A wheel turned over grid cell `mc` (SDL's sign: up positive): the +/// part of dispatch after the pointer is placed, so a test can turn it. +fn wheelAt(g: *Gui, in: *Input, w: WheelTurn, mc: MouseCell) void { + if (w.y != 0 and std.math.isFinite(w.y)) { + if (in.core) |core| { + const hit: ?usize = for (core.panes, 0..) |slot, i| { + if (slot == null) continue; + const r = core.rects[i]; + if (mc.col >= r.x and mc.col < r.x + r.w and mc.row >= r.y and mc.row < r.y + r.h) break i; + } else null; + if (g.scroll_pane) |old| if (hit == null or hit.? != old) { + resetScroll(g); + }; + if (hit) |id| { + const pdf_target = if (comptime pardes.pdf_enabled) + core.native_images and core.panes[id].?.pdfPage() != null + else + false; + const pane = core.panes[id].?; + if (pdf_target) { + resetScroll(g); + in.post(.{ .pdf_scroll = .{ + .pane = @intCast(id), + .delta_pixels = -w.y * @as(f32, @floatFromInt(g.cell_h)), + } }); + } else if (pane.isTerminal() and pardes.panes.terminal.ownsWheel(pane)) { + // The program's (codex, htop, less): every whole + // turn goes to it, no glide, for nothing of ours + // moves. + resetScroll(g); + g.program_wheel = accumulateWheelDelta(g.program_wheel, w.y); + while (g.program_wheel >= 1) : (g.program_wheel -= 1) + in.post(.{ .mouse = .{ .button = .wheel_down, .kind = .press, .col = mc.col, .row = mc.row, .tag_hit = mc.tag_hit, .body_hit = mc.body_hit } }); + while (g.program_wheel <= -1) : (g.program_wheel += 1) + in.post(.{ .mouse = .{ .button = .wheel_up, .kind = .press, .col = mc.col, .row = mc.row, .tag_hit = mc.tag_hit, .body_hit = mc.body_hit } }); + } else { + g.scroll_pane = id; + g.scroll_col = mc.col; + g.scroll_row = mc.row; + // A mouse wheel's notch is a whole line and + // glides (G6); a touchpad's fraction moves the + // picture by itself, under the finger. + if (notchOf(w.y, w.integer_y)) |lines| { + g.scroll_notch +|= lines; + } else g.scroll_delta = accumulateWheelDelta(g.scroll_delta, w.y); + } + } + } else { + g.scroll_delta = accumulateWheelDelta(g.scroll_delta, w.y); + while (g.scroll_delta >= 1) : (g.scroll_delta -= 1) + in.post(.{ .mouse = .{ .button = .wheel_down, .kind = .press, .col = mc.col, .row = mc.row, .tag_hit = mc.tag_hit, .body_hit = mc.body_hit } }); + while (g.scroll_delta <= -1) : (g.scroll_delta += 1) + in.post(.{ .mouse = .{ .button = .wheel_up, .kind = .press, .col = mc.col, .row = mc.row, .tag_hit = mc.tag_hit, .body_hit = mc.body_hit } }); + } + } + if (w.x != 0) in.post(.{ .mouse = .{ + .button = if (w.x > 0) .wheel_right else .wheel_left, + .kind = .press, + .col = mc.col, + .row = mc.row, + .tag_hit = mc.tag_hit, + .body_hit = mc.body_hit, + } }); +} + +const WheelTurn = struct { x: f32 = 0, y: f32 = 0, integer_y: i32 = 0 }; + fn mouseCellWithGeometry(g: *const Gui, core: ?*const pardes.Pardes, x: f32, y: f32, geometry: WindowGeometry) ?MouseCell { const physical = windowPointToPixels(geometry, x, y); return gridCellAtPixels(g, core, physical.x, physical.y); @@ -4896,6 +4918,69 @@ test "a wheel's notch is whole lines, a touchpad's fraction is not" { try std.testing.expectEqual(@as(?i32, null), notchOf(std.math.nan(f32), 1)); } +test "a wheel over codex in a terminal reaches its pty as the mouse reports it asked for" { + if (comptime !pardes.panes.terminal.enabled) return error.SkipZigTest; + const gpa = std.testing.allocator; + const core = try pardes.Pardes.init(gpa, .{ .cols = 60, .rows = 16, .tty_only = true }); + defer core.deinit(); + var frame: std.heap.ArenaAllocator = .init(gpa); + defer frame.deinit(); + _ = try core.render(frame.allocator()); + var g: Gui = undefined; + g.scroll_pane = null; + g.scroll_delta = 0; + g.scroll_lag = 0; + g.scroll_edge_len = 0; + g.scroll_notch = 0; + g.program_wheel = 0; + g.glide = .{}; + g.cell_h = 20; + var in: Input = .{ .core = core }; + var sent: std.ArrayList(u8) = .empty; + defer sent.deinit(gpa); + const Sent = struct { + fn take(core_: *pardes.Pardes, out: *std.ArrayList(u8)) ![]const u8 { + out.clearRetainingCapacity(); + while (core_.nextEffect()) |effect| { + if (effect == .write and effect.write.pane == 0) try out.appendSlice(std.testing.allocator, effect.write.bytes.slice()); + } + return out.items; + } + }; + _ = try Sent.take(core, &sent); + const pane = core.panes[0].?; + const r = core.rects[0]; + const mc: MouseCell = .{ .col = r.x + config.GUTTER + 5, .row = core.bodyTop(pane, r) + 3 }; + // What codex 0.159's chat sets on start. + core.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?2004h\x1b[>7u\x1b[?1004h\x1b[?1049h\x1b[?1007l\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1006h" } }); + _ = try Sent.take(core, &sent); + // A notch up, then a fast turn of three down: each its report, none + // left behind for a glide. + wheelAt(&g, &in, .{ .y = 1, .integer_y = 1 }, mc); + try std.testing.expectEqualStrings("\x1b[<64;6;4M", try Sent.take(core, &sent)); + wheelAt(&g, &in, .{ .y = -3, .integer_y = -3 }, mc); + try std.testing.expectEqualStrings("\x1b[<65;6;4M" ** 3, try Sent.take(core, &sent)); + try std.testing.expectEqual(@as(i32, 0), g.scroll_notch); + try std.testing.expect(g.scroll_pane == null); + // A touchpad's fractions add up to a turn. + wheelAt(&g, &in, .{ .y = 0.5 }, mc); + try std.testing.expectEqualStrings("", try Sent.take(core, &sent)); + wheelAt(&g, &in, .{ .y = 0.5 }, mc); + try std.testing.expectEqualStrings("\x1b[<64;6;4M", try Sent.take(core, &sent)); + // Its sign-in screen: mouse off, alternate scroll on, so arrow keys. + core.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?1006l\x1b[?1003l\x1b[?1002l\x1b[?1000l\x1b[?1007h" } }); + _ = try Sent.take(core, &sent); + wheelAt(&g, &in, .{ .y = -1, .integer_y = -1 }, mc); + try std.testing.expectEqualStrings("\x1b[B", try Sent.take(core, &sent)); + // Back on the main screen a shell's wheel is ours: a notch to glide, + // nothing to the pty. + core.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?1049l" } }); + _ = try Sent.take(core, &sent); + wheelAt(&g, &in, .{ .y = 1, .integer_y = 1 }, mc); + try std.testing.expectEqualStrings("", try Sent.take(core, &sent)); + try std.testing.expectEqual(@as(i32, -1), g.scroll_notch); +} + /// G6 (docs/render-pipeline.md §9.1): a wheel's lines scroll the core at /// once, so every key and click acts on where the text is, and the picture /// glides there on the Motion flavour's spring (critically damped: text 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); +} diff --git a/src/terminal.zig b/src/terminal.zig index 1a3b6a21..4751a09f 100644 --- a/src/terminal.zig +++ b/src/terminal.zig @@ -385,6 +385,55 @@ pub fn mouseFormatSgr(pane: *const Pane) bool { return state.vt.modes.get(.mouse_format_sgr); } +/// A wheel over the program is the program's: it tracks the mouse, or it is +/// on the alternate screen, which has no scrollback, with alternate scroll +/// (DECSET 1007, on unless turned off) asking for arrow keys instead. +pub fn ownsWheel(pane: *const Pane) bool { + if (comptime !enabled) return false; + const state = pane.terminal orelse return false; + return reportsMouse(pane) or + (state.vt.screens.active_key == .alternate and state.vt.modes.get(.mouse_alternate_scroll)); +} + +pub const Wheel = enum(u8) { up = 64, down = 65, left = 66, right = 67 }; + +/// The bytes a wheel turn at grid cell `col`,`row` (0-based) sends the +/// program, as xterm sends them: its mouse report in the format it asked +/// for, else, on the alternate screen, an arrow key (none for a sideways +/// turn). Null when the wheel is not the program's (`ownsWheel`) or the +/// cell cannot be said in the legacy format. +pub fn wheelBytes(pane: *const Pane, buf: *[32]u8, wheel: Wheel, col: u16, row: u16, ctrl: bool) ?[]const u8 { + if (comptime !enabled) return null; + const state = pane.terminal orelse return null; + if (!ownsWheel(pane)) return null; + const m = &state.vt.modes; + if (!reportsMouse(pane)) return switch (wheel) { + .up => if (m.get(.cursor_keys)) "\x1bOA" else "\x1b[A", + .down => if (m.get(.cursor_keys)) "\x1bOB" else "\x1b[B", + .left, .right => null, + }; + const code = @intFromEnum(wheel) + @as(u8, if (ctrl) 16 else 0); + const x = @as(u32, col) + 1; + const y = @as(u32, row) + 1; + if (m.get(.mouse_format_sgr)) return std.fmt.bufPrint(buf, "\x1b[<{d};{d};{d}M", .{ code, x, y }) catch null; + if (m.get(.mouse_format_urxvt)) return std.fmt.bufPrint(buf, "\x1b[{d};{d};{d}M", .{ 32 + @as(u32, code), x, y }) catch null; + buf[0..3].* = "\x1b[M".*; + buf[3] = 32 + code; + var n: usize = 4; + for ([_]u32{ x, y }) |v| { + const c = 32 + v; + if (m.get(.mouse_format_utf8)) { + if (c > 2047) return null; + n += std.unicode.utf8Encode(@intCast(c), buf[n..]) catch return null; + } else { + if (c > 255) return null; + buf[n] = @intCast(c); + n += 1; + } + } + return buf[0..n]; +} + /// The whole scrollback as plain text, `gpa`-owned: what a terminal's body /// reads and `Save` writes out. Logical lines: rows the terminal wrapped /// are joined again (ghostty's wrap flags, as pty/run's output is), and |
