summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-30 14:39:50 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:17 -0300
commite56a8ba396566eaab818b6c742850b5842e880bb (patch)
tree346e0ccf2b1a738bee2b7a185ff560e324c85417
parent1d0f8cd158bb47941c0187d32422787c92e8feeb (diff)
parent387d6a39bad76c5eb2f56328f7cf7869a9a10559 (diff)
downloadpardes-e56a8ba396566eaab818b6c742850b5842e880bb.tar.gz
pardes-e56a8ba396566eaab818b6c742850b5842e880bb.zip
Merge: round 26 + codex wheel (the 0.8 candidate)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
-rw-r--r--build.zig.zon2
-rw-r--r--src/CHANGELOG.md25
-rw-r--r--src/gui/gui.zig183
-rw-r--r--src/mouse.zig85
-rw-r--r--src/terminal.zig49
5 files changed, 292 insertions, 52 deletions
diff --git a/build.zig.zon b/build.zig.zon
index 4d661759..8283c2c2 100644
--- a/build.zig.zon
+++ b/build.zig.zon
@@ -1,6 +1,6 @@
.{
.name = .pardes,
- .version = "0.0.7",
+ .version = "0.0.8",
.dependencies = .{
// 9P sessions and transports live in the published cloud9 package, so
// the manifest pins the build: `zig build` fetches this commit into
diff --git a/src/CHANGELOG.md b/src/CHANGELOG.md
index 3dd7c996..baff96b0 100644
--- a/src/CHANGELOG.md
+++ b/src/CHANGELOG.md
@@ -1,5 +1,30 @@
# Changelog
+## 0.0.8
+
+- A wheel over a terminal whose program asked for the mouse goes to the
+ program, as a report in the format it chose, or as arrow keys on the
+ alternate screen with alternate scroll: codex, htop, less and vim scroll.
+- `pardes FILE` in a pane whose session answers never starts a nested
+ editor: a refusal is printed and it exits 1. A tty pardes killed by
+ SIGTERM or SIGHUP puts its terminal back first.
+- A 9P connection holds up to 128 waiting reads, and refuses the next in
+ words; 9ns (cloud9 705be665) no longer deadlocks a mount under many held
+ reads.
+- A backward search along one long line takes time in its length, not its
+ square. A detached session no client watches draws no frames: 50 MB of
+ output through `pty/run` takes 11 s, not over 30.
+- Save makes its file's missing directories, for its own name and `Save
+ path` alike. `pane/new` takes rows from another column before refusing.
+- With no pane at all, the root `look` opens its file and the root `exec`
+ runs in the session's directory. A refused `Newcol` logs only its error.
+- `Lspwhy` explains the last query in its own file and place; every `+Lsp`
+ answer shares one pane per directory.
+- DumpConfig's information lines start with `# `, and a line starting with
+ `#` runs as nothing. `winsize` refuses fewer than 2 rows. `look file:3:0`
+ is refused. `Kill X` with nothing running is a message, not a failure.
+ An event write-back needs no trailing newline.
+
## 0.0.7
- `lapis` draws its cells unblended and each plaque pixel once: about a
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 4cac6f1d..7874ca89 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