summaryrefslogtreecommitdiff
path: root/src/gui/gui.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-26 18:58:37 -0300
committerGabriel Schneider <[email protected]>2026-08-27 09:47:39 -0300
commit29ac9be75fdcafbd7d05c15aa9eb8490d74caa98 (patch)
tree6629cc215d6953090f6b29a7414b28cb9990e105 /src/gui/gui.zig
parent11f380f6d7222f2cad93c2cdf13701ea1f903d47 (diff)
downloadpardes-29ac9be75fdcafbd7d05c15aa9eb8490d74caa98.tar.gz
pardes-29ac9be75fdcafbd7d05c15aa9eb8490d74caa98.zip
An edited row keeps its colours, four copies of forkShell become one, and Esc stops recentring
## A terminal row's ANSI colours survive being edited The loudest colour bug this editor had: one keystroke anywhere in a coloured shell row turned EVERY column of it grey. `EditAnchors` anchored a buffer line only when it was BYTE-IDENTICAL to the shell row it stood over, so a single differing byte dropped the whole row's colour projection. Worst shape is invisible: append past the pane's right edge, where the text is clipped, and the row looks the same and only its colour goes. Anchoring is byte-level now. An edit leaves the row's own bytes at both ends, and being the same bytes they keep the same colours; only what was typed has no cell under it, so only that takes none. Live, on real `fastfetch`: a 32-column blue run split into 6 + 26 around one typed character. Three defects underneath it, all found by machinery rather than by reading: * A JOIN removes a buffer line while the buffer's covered span grows, so `lines == covered` and both aligned guesses — Nth line over the Nth covered row, and the same counted from the bottom — resolved to the SAME wrong row. Every untouched row below a join went plain. Anchoring is now a streaming monotone matching: one shell-row cursor that only ever moves forward, advanced once per buffer line, linear in the buffer where the version before it was quadratic. * An EMPTY line is not evidence. Splitting a row makes one, it equals every blank row in the span, and left free to look ahead it claimed the blank row below the last output and took every coloured row in between out of reach of the lines that owned them. * Reflow under a scrolled viewport. `PageList.getTopLeft(.viewport)` returns the viewport pin verbatim, x and all, while `PageList.pin` forces x to 0 — so after a reflow remapped a tracked pin into the middle of a row, the text pass dumped row 0 from that column while the colour pass paired the fragment with the row's FIRST cells. Row 0 wore its left half's colours until the pane snapped back to live output. `bodyText` dumps from column zero now, which is also what ghostty's own renderer draws. Also here: DECSCNM (reverse video) was silently dropped whenever `tty_filter` was off, because the raw path resolved a `.none` colour by role and never consulted the mode. The test that found the first two is the one worth keeping: random editing against an ABSOLUTE oracle — every row's own text names the colour it must have — because the differential oracle it replaced was blind by construction. It skipped the edited row, which is the row the user is complaining about. ## Esc returns to a pane without moving its view Esc in body normal mode runs `Last`, "the pane you were in before this one", and that went through `focusPaneLine`, which recentred a file on the target line unconditionally. So returning to a buffer repainted the whole screen to show a line that was already on it. `focusPaneLine` takes a landing now: `.center` for the three callers going somewhere you have not been (a look target, a path a pane already holds, `@pN:LINE:COL`), `.keep` for Esc. `.keep` leaves the view alone and lets `ensureCursorVisible` — which already existed and already scrolls by the minimum into the `scroll_off` band — be the only thing that may move anything. Not `line = 0`, which `focusPaneLine` already understands as "focus and touch nothing": a background pane's view can move while you are away, because the wheel scrolls the pane under the POINTER and a resize reveals no cursor, so the recorded cursor plus a minimal nudge is what actually gets you back. Ctrl-o and Ctrl-i keep centring, and the asymmetry is structural rather than arbitrary: `Last` only ever CROSSES panes, so the pane it lands on already holds the view you left it with, while `jumpBy` can land in the SAME pane, where a long in-file jump would arrive on the very top or bottom row with `scroll_off` lines of context on one side. Helix splits the same pair the same way — its jumplist centres, its buffer switch does not. One deliberate consequence: under `.keep` a PDF's page is not restored AT ALL, because a page reveal IS that pane's view and a reveal of the page you are already on still snaps `document_scroll_y` to that page's start, discarding where you had read to. When something moved the pane while you were away — the wheel again — Esc leaves it where the wheel left it, and Ctrl-o is how you reach the recorded page. ## host_io.zig: the machine-local half of a host, once `host.zig` is the seam. The part of the answer that is identical on every host with an operating system under it — fork a pane's shell, put bytes on a disk — was written FOUR times: in tty.zig, gui.zig, macos.zig and detached/server.zig. What those copies had in common says what they were for: all four were missing FD_CLOEXEC on the pty master, so in every shell pardes has shipped, a program in one pane could read another pane's terminal. One copy now, and the wire got smaller for it: `ServerMsg.spawn` is gone. A frontend never asked the server to fork anything — the server has an operating system under it and forks through `host_io` like every other host — and `decodeClient` lost the scratch buffer that message needed.
Diffstat (limited to 'src/gui/gui.zig')
-rw-r--r--src/gui/gui.zig976
1 files changed, 721 insertions, 255 deletions
diff --git a/src/gui/gui.zig b/src/gui/gui.zig
index 05008d0b..c42e390b 100644
--- a/src/gui/gui.zig
+++ b/src/gui/gui.zig
@@ -5,6 +5,26 @@
//! FreeType-hinted R8 atlas. Test modes: PARDES_TEST_GRID=1 is headless (no SDL,
//! stdin escape sequences in, text grid frames out); PARDES_TEST=1 keeps the
//! real renderer, drives input from stdin, and captures frames to PPM.
+//!
+//! ...AND THE SAME WINDOW WITH NO CORE IN IT. `--attach`, and the `Attach`
+//! builtin, hand this window's screen to a detached session (src/detached/):
+//! the `Pardes` lives in THAT process, and this one sends the input it collects
+//! and paints the frames it is sent. The two modes share every line that
+//! touches SDL — `dispatch`/`keyDown` translate an SDL_Event once,
+//! `renderFrame` rasterizes a `Surface` once, `putClipboard`/`takeClipboard`
+//! and `look.openLink` are the desktop once — and differ only in where a
+//! translated event goes and where the cells came from. `Input` is that seam,
+//! and a null `core` inside it is what "attached" MEANS here: every function
+//! that reads pane rects or theme colours off the core takes an optional one
+//! and falls back to the body grid, because the wire carries cells, not the
+//! layout that produced them.
+//!
+//! An attached window does NO machine-local work whatsoever: it forks no shell,
+//! writes no file and watches no path, because the session process owns all of
+//! that now (src/host_io.zig, src/file_watch.zig, src/detached/server.zig).
+//! The only effects still on that wire are the three that need a human's own
+//! display — `set_clipboard`, `read_clipboard`, `open_link` — and those land on
+//! THIS display.
const std = @import("std");
const builtin = @import("builtin");
const posix = std.posix;
@@ -29,16 +49,25 @@ const nested = @import("../nested.zig");
const fuse = @import("../fuse.zig");
const fs_service = @import("../fs_service.zig");
+// The other half of `--detach`, and the reason this file has an `--attach`
+// branch at all: the frontend side of a detached session is a window and a
+// socket, and this file is already the one that owns a window. Just the one
+// import: client.zig owns the frontend's whole side of this transport — the
+// name resolution, the handshake, the poll interval and the decoded messages —
+// so nothing here reaches past it to server.zig or wire.zig.
+const detached_client = @import("../detached/client.zig");
+
pub const c = @cImport({
@cDefine("SDL_DISABLE_OLD_NAMES", "1");
@cInclude("SDL3/SDL.h");
@cInclude("font.h");
});
-extern "c" fn forkpty(amaster: *c_int, name: ?[*:0]u8, termp: ?*const anyopaque, winp: ?*const posix.winsize) c_int;
-extern "c" fn execv(path: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int;
-extern "c" fn chdir(path: [*:0]const u8) c_int;
-extern "c" fn _exit(status: c_int) noreturn;
+// The machine-local half of a host — fork a pane's shell, put bytes on a disk
+// — is shared with the tty shell and the detached daemon. This file used to
+// carry its own `forkShell`, `writeWholeFile` and `writeFd`, ten of eleven
+// lines identical to that file's and one line short of its zero-write guard.
+const host_io = @import("../host_io.zig");
extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int;
// TIOCSWINSZ: absent from std.c.T on darwin — _IOW('t', 103, winsize)
const TIOCSWINSZ: c_int = @bitCast(@as(u32, if (@hasDecl(posix.T, "IOCSWINSZ")) posix.T.IOCSWINSZ else 0x80087467));
@@ -786,12 +815,12 @@ fn takeScrollTicks(accum: *f32) i32 {
}
/// The SDL boundary owns touch policy: two-finger scroll and tap-as-execute.
-fn handleFinger(t: *Touch, core: *pardes.Pardes, f: Finger, win_w: f32, win_h: f32, cell_w: f32, cell_h: f32, tagline_w: f32) void {
+fn handleFinger(t: *Touch, in: *Input, f: Finger, win_w: f32, win_h: f32, cell_w: f32, cell_h: f32, tagline_w: f32) void {
std.debug.assert(cell_w > 0 and cell_h > 0);
- return handlePairFinger(t, core, f, win_w, win_h, cell_w, cell_h, tagline_w);
+ return handlePairFinger(t, in, f, win_w, win_h, cell_w, cell_h, tagline_w);
}
-fn handlePairFinger(t: *Touch, core: *pardes.Pardes, f: Finger, win_w: f32, win_h: f32, cell_w: f32, cell_h: f32, tagline_w: f32) void {
+fn handlePairFinger(t: *Touch, in: *Input, f: Finger, win_w: f32, win_h: f32, cell_w: f32, cell_h: f32, tagline_w: f32) void {
switch (f.kind) {
.down => {
t.updatePoint(f);
@@ -803,33 +832,33 @@ fn handlePairFinger(t: *Touch, core: *pardes.Pardes, f: Finger, win_w: f32, win_
t.syncPair();
if (!had_pair) return;
const res = t.noteMotion(f) orelse return;
- emitTouchScroll(core, res.center, res.ticks, res.delta_y, win_w, win_h, cell_w, cell_h, tagline_w);
+ emitTouchScroll(in, res.center, res.ticks, res.delta_y, win_w, win_h, cell_w, cell_h, tagline_w);
},
.up, .cancel => {
const tap_center = t.finishPair(f);
t.updatePoint(f);
t.syncPair();
- if (tap_center) |center| emitTouchClick(t, core, center, .middle, win_w, win_h, cell_w, cell_h, tagline_w);
+ if (tap_center) |center| emitTouchClick(t, in, center, .middle, win_w, win_h, cell_w, cell_h, tagline_w);
},
}
}
-fn emitTouchScroll(core: *pardes.Pardes, center: TouchNormPoint, ticks: i32, delta_y: f32, win_w: f32, win_h: f32, cell_w: f32, cell_h: f32, tagline_w: f32) void {
- const cell = gridCellAtDimensions(core, center.x * win_w, center.y * win_h, cell_w, cell_h, tagline_w);
+fn emitTouchScroll(in: *Input, center: TouchNormPoint, ticks: i32, delta_y: f32, win_w: f32, win_h: f32, cell_w: f32, cell_h: f32, tagline_w: f32) void {
+ const cell = gridCellAtDimensions(in.core, center.x * win_w, center.y * win_h, cell_w, cell_h, tagline_w);
const button: pardes.Mouse.Button = if (ticks > 0) .wheel_up else .wheel_down;
var remaining: u32 = @abs(ticks);
while (remaining > 0) : (remaining -= 1)
- core.update(.{ .mouse = .{ .button = button, .kind = .press, .col = cell.col, .row = cell.row } });
- core.update(.{ .touch_scroll = delta_y });
+ in.post(.{ .mouse = .{ .button = button, .kind = .press, .col = cell.col, .row = cell.row } });
+ in.post(.{ .touch_scroll = delta_y });
}
-fn emitTouchClick(t: *Touch, core: *pardes.Pardes, point: TouchNormPoint, button: pardes.Mouse.Button, win_w: f32, win_h: f32, cell_w: f32, cell_h: f32, tagline_w: f32) void {
+fn emitTouchClick(t: *Touch, in: *Input, point: TouchNormPoint, button: pardes.Mouse.Button, win_w: f32, win_h: f32, cell_w: f32, cell_h: f32, tagline_w: f32) void {
t.click_count +%= 1;
t.click_button = button;
t.click_flash_frames = touch_click_flash_max_frames;
- const cell = gridCellAtDimensions(core, point.x * win_w, point.y * win_h, cell_w, cell_h, tagline_w);
- core.update(.{ .mouse = .{ .button = button, .kind = .press, .col = cell.col, .row = cell.row } });
- core.update(.{ .mouse = .{ .button = button, .kind = .release, .col = cell.col, .row = cell.row } });
+ const cell = gridCellAtDimensions(in.core, point.x * win_w, point.y * win_h, cell_w, cell_h, tagline_w);
+ in.post(.{ .mouse = .{ .button = button, .kind = .press, .col = cell.col, .row = cell.row } });
+ in.post(.{ .mouse = .{ .button = button, .kind = .release, .col = cell.col, .row = cell.row } });
}
fn normCell(norm: f32, win: f32, cell: f32) u16 {
@@ -1451,6 +1480,24 @@ fn windowGeometry(window: *c.SDL_Window) WindowGeometry {
};
}
+/// This window in whole cells, which is the grid the core is asked to be. The
+/// one derivation of it: `pollFrame` follows the window with it every frame,
+/// `refitFont` re-asks after Ctrl+/Ctrl- has moved the cell under it, and both
+/// attach paths tell the session what this window can show with it. A window
+/// that is not a whole number of cells across has to round the same way in all
+/// four places or the last row lands off the bottom edge.
+const GridCells = struct { cols: u16, rows: u16 };
+
+fn windowCells(g: *const Gui) GridCells {
+ var pw: c_int = 0;
+ var ph: c_int = 0;
+ _ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph);
+ return .{
+ .cols = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(pw, 1))), g.cell_w))),
+ .rows = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(ph, 1))), g.cell_h))),
+ };
+}
+
fn windowPointToPixels(geometry: WindowGeometry, x: f32, y: f32) crt.Point {
return .{
.x = x * geometry.pixel_w / geometry.window_w,
@@ -1485,30 +1532,40 @@ fn compactTaglineLayout(g: *const Gui, origin_col: f32) CellLayout {
};
}
+/// Where a tagline cell's compact band begins. `core` is null in an attached
+/// window, and then EVERY tagline cell takes the last line's fallback: the
+/// wire carries cells, not the pane rects that placed them, so there is no
+/// band origin to compact against. That is the same answer `gridCellAtDimensions`
+/// reaches for the same reason, which is what keeps the two honest — a click
+/// lands on the glyph it was aimed at, because both sides map through the body
+/// grid. The visible cost is one tagline row's worth of loose tracking.
fn taglineLayoutForCell(
g: *const Gui,
- core: *const pardes.Pardes,
+ core: ?*const pardes.Pardes,
col: u16,
row: u16,
track: ?pardes.panel_animation.Track,
) CellLayout {
if (row < pardes.TOPBAR_H) return compactTaglineLayout(g, 0);
- if (track) |active| {
- const box = active.contentBox();
- const tag_y = if (core.settings.tag_bottom) box.y + box.h - @as(f32, @floatFromInt(pardes.BOX_H)) else box.y;
- if (@as(f32, @floatFromInt(row)) >= tag_y and
- @as(f32, @floatFromInt(row)) < tag_y + @as(f32, @floatFromInt(pardes.BOX_H)))
- return compactTaglineLayout(g, box.x);
- }
- for (core.panes, 0..) |slot, id| {
- if (slot == null) continue;
- const r = core.rects[id];
- const tag_y = if (core.settings.tag_bottom) r.y + r.h -| pardes.BOX_H else r.y;
- if (row == tag_y and col >= r.x and col < r.x + r.w)
- return compactTaglineLayout(g, @floatFromInt(r.x));
+ if (core) |p| {
+ if (track) |active| {
+ const box = active.contentBox();
+ const tag_y = if (p.settings.tag_bottom) box.y + box.h - @as(f32, @floatFromInt(pardes.BOX_H)) else box.y;
+ if (@as(f32, @floatFromInt(row)) >= tag_y and
+ @as(f32, @floatFromInt(row)) < tag_y + @as(f32, @floatFromInt(pardes.BOX_H)))
+ return compactTaglineLayout(g, box.x);
+ }
+ for (p.panes, 0..) |slot, id| {
+ if (slot == null) continue;
+ const r = p.rects[id];
+ const tag_y = if (p.settings.tag_bottom) r.y + r.h -| pardes.BOX_H else r.y;
+ if (row == tag_y and col >= r.x and col < r.x + r.w)
+ return compactTaglineLayout(g, @floatFromInt(r.x));
+ }
}
- // A stale/closing cell without a live pane should still remain legible.
- // Its body-grid origin is the only safe fallback available.
+ // A stale/closing cell without a live pane should still remain legible,
+ // and so should every cell of an attached window. Its body-grid origin is
+ // the only safe fallback available.
return compactTaglineLayout(g, @floatFromInt(col));
}
@@ -1639,11 +1696,23 @@ fn setTransitionFields(
pub const run = runNative;
-fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
- const io = init.io;
+/// `attach` is `--attach[=<name>]`: empty means "the session there is" (see
+/// `detached_client.resolve`). It is a parameter rather than an `Options` field
+/// because it says nothing to the core — this process does not have one when it
+/// is set.
+fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u8) !void {
const gpa = init.gpa;
const env = init.environ_map;
- if (env.get("PARDES_TEST_GRID") != null) return runGrid(init, opts_in);
+ // PARDES_TEST_GRID owns the process when it is set, and it is headless.
+ // There is no window to hand to a session, so refuse the combination
+ // rather than silently dropping the flag a harness meant.
+ if (env.get("PARDES_TEST_GRID") != null) {
+ if (attach != null) {
+ log.err("--attach needs a window; PARDES_TEST_GRID is headless", .{});
+ return error.AttachNeedsWindow;
+ }
+ return runGrid(init, opts_in);
+ }
const test_mode = env.get("PARDES_TEST") != null;
const capture_dir = env.get("PARDES_TEST_CAPTURE_DIR");
@@ -1865,7 +1934,40 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
_ = c.ui_font_raster(font, scale, ' ', atlas_stage.ptr, @intCast(atlas_w), @intCast(cell_w), @intCast(cell_h), asc);
g.pen_x = cell_w;
- // ---- the core ----
+ // `--attach`: this process has a window and NO core. Everything above is
+ // the window and the rasterizer, which an attached frontend needs exactly
+ // as much as a whole session does; everything below is the core, which
+ // lives in the detached process (src/detached/). The branch is here so both
+ // leave by the same door — the GPU objects, the glyph atlas and the face
+ // are put away by the defers above whichever mode ran.
+ if (attach) |requested| return attachRequested(gpa, &g, requested);
+ // ...and the same handover arrived at from the other side: the `Attach`
+ // builtin gives this window to a session mid-flight. `localSession` returns
+ // a CONNECTED client only, and by the time it does every pane shell, watch
+ // and mount of the local session is already away.
+ var attached: ?detached_client.Client = null;
+ try localSession(init, &g, opts_in, test_mode, &attached);
+ if (attached) |*client| return attachedLoop(gpa, &g, client);
+}
+
+/// The session that lives in THIS process: the core, its pane shells, its
+/// watches, its acme filesystem and the loop that pumps them. A function of its
+/// own rather than the tail of `runNative` because that makes its teardown a
+/// scope exit instead of a second copy of the same twelve defers — and the
+/// `Attach` builtin needs exactly that teardown, in exactly that LIFO order,
+/// before an attached loop may draw on the same window.
+///
+/// `attached` is how a connected client leaves: it is set only after a
+/// handshake is in flight, which is what makes a failed `Attach` a no-op.
+fn localSession(
+ init: std.process.Init,
+ g: *Gui,
+ opts_in: pardes.Options,
+ test_mode: bool,
+ attached: *?detached_client.Client,
+) !void {
+ const io = init.io;
+ const gpa = init.gpa;
const allocs = pardes.allocators.init(gpa);
defer pardes.allocators.deinit();
pardes.image.start(io, allocs.image); // stb_image allocator for image panes
@@ -1891,9 +1993,9 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
var pw: c_int = 0;
var ph: c_int = 0;
if (opts.load_path != null) {
- _ = c.SDL_GetWindowSizeInPixels(window, &pw, &ph);
- opts.cols = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(pw, 1))), cell_w)));
- opts.rows = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(ph, 1))), cell_h)));
+ _ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph);
+ opts.cols = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(pw, 1))), g.cell_w)));
+ opts.rows = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(ph, 1))), g.cell_h)));
}
var core = if (opts.load_path) |lp| blk: {
const bytes = try @import("../look.zig").readFile(gpa, lp);
@@ -1905,8 +2007,8 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
// construction: argv image panes no longer freeze the startup capability
// into their PETSCII preference, so their first render emits attachments.
core.native_images = true;
- observeGuiFont(&g, core);
- syncTaglineFont(&g, core);
+ observeGuiFont(g, core);
+ syncTaglineFont(g, core);
// Private, complete before any fork and retained until the last possible
// spawn; children borrow only these stable in-struct path buffers.
@@ -1967,7 +2069,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
var shell: Shell = .{
.core = core,
- .gui = &g,
+ .gui = g,
.io = io,
.gpa = gpa,
.lsp_allocator = allocs.lsp,
@@ -2003,17 +2105,46 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
// it a control pipe that CAN wake it out of poll().
fs_service.wake(fs, &queue, wakeFs);
- _ = c.SDL_StartTextInput(window);
+ _ = c.SDL_StartTextInput(g.window);
if (test_mode) setStdinRaw() catch {};
shell.threads_ok = true;
- // The core owns the loop. This owns the one thing a pump cannot do from
- // inside itself: Restore swaps the whole Pardes, which is only safe
- // BETWEEN iterations.
+ // The core owns the loop. This owns the two things a pump cannot do from
+ // inside itself, because both replace the whole session and are only safe
+ // BETWEEN iterations: Restore swaps the `Pardes`, and Attach retires it.
while (!core.quit) {
try core.pump(host);
if (core.quit) break; // a session that ended does not restore into one
+ // Attach builtin: hand this window's screen to a detached session.
+ //
+ // GREET FIRST, SWAP SECOND, and that order IS the feature.
+ // `detached_client.attempt` resolves, connects AND waits for the
+ // `welcome`, and closes whatever it opened on every other outcome — so
+ // when this returns anything but `.greeted`, nothing below has run and
+ // this instance is exactly as it was: every pane, every shell, every
+ // unsaved buffer, the whole undo history. It says why on the row of the
+ // pane that ran the word and the session goes on. A half-torn-down
+ // editor is the one outcome an attach must never have, and `open` alone
+ // cannot rule it out — a `refuse .version` from a session built by the
+ // last `zig build` arrives AFTER the connect.
+ if (core.takeAttach()) |req| {
+ const geom = windowCells(g);
+ const outcome = detached_client.attempt(gpa, req.name, geom.cols, geom.rows);
+ switch (outcome) {
+ .greeted => |client| {
+ // Greeted, so this session is over: the `break` runs the
+ // filesystem unmount below and then every defer above, and
+ // `runNative` picks the client up on the far side.
+ attached.* = client;
+ break;
+ },
+ else => {
+ var mbuf: [256]u8 = undefined;
+ core.setMessage(req.pane, attachFailure(&mbuf, outcome, req.name));
+ },
+ }
+ }
// Restore builtin: swap in a core rebuilt from the dump; kill the live
// shells (their detached readers wake on child death; gens bumped so
// the stale eofs close the old fds without touching the replay panes)
@@ -2041,7 +2172,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
.{ .text = 0 },
);
_ = file_watch.applyThemeEffect(core, gpa, inotify_fd, &watches, 0, false, false);
- clearNativeImages(&g);
+ clearNativeImages(g);
g.presented_images.clearRetainingCapacity();
g.prepared_images.clearRetainingCapacity();
nc.native_images = true;
@@ -2050,8 +2181,8 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
core = nc;
shell.core = nc;
shell.surface = null;
- observeGuiFont(&g, core);
- syncTaglineFont(&g, core);
+ observeGuiFont(g, core);
+ syncTaglineFont(g, core);
}
}
@@ -2066,6 +2197,243 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
}
}
+// =====================================================================
+// --attach: a window, a socket, and no core
+// =====================================================================
+
+/// What to say when an attach did not happen. One function for both callers
+/// because it is one set of outcomes: `--attach` logs it to a terminal it has
+/// not drawn over yet, the `Attach` word puts it on the pane's message row, and
+/// neither should be inventing its own wording for `refuse .version`.
+///
+/// `requested` is the word a person typed, empty for "the session that is
+/// there" — which is the whole difference between "no session called work" and
+/// "nothing is detached".
+fn attachFailure(buf: []u8, outcome: detached_client.Attempt, requested: []const u8) []const u8 {
+ return switch (outcome) {
+ // The caller took this one and never asks.
+ .greeted => unreachable,
+ .no_session => if (requested.len != 0)
+ std.fmt.bufPrint(buf, "Attach: no detached session called '{s}'", .{requested}) catch
+ "Attach: no detached session under that name"
+ else
+ "Attach: nothing is detached (start one with `pardes --detach`)",
+ .ambiguous => |n| std.fmt.bufPrint(buf, "Attach: {d} detached sessions; say which one", .{n}) catch
+ "Attach: several detached sessions; say which one",
+ .refused => |why| switch (why) {
+ .version => "Attach: that session speaks a different wire version — it is another build of pardes",
+ .full => "Attach: that session already has every frontend slot taken",
+ .quitting => "Attach: that session is ending",
+ },
+ .silent => "Attach: that session took the connection and never greeted us",
+ .lost => |err| std.fmt.bufPrint(buf, "Attach: lost the connection ({t})", .{err}) catch
+ "Attach: lost the connection",
+ };
+}
+
+/// `--attach[=<name>]`: this window is a frontend from its first frame. Split
+/// from `attachedLoop` because the two arrive with different evidence — a
+/// command line has a person at a terminal to tell when there is nothing to
+/// attach to and a process exit status to carry it, while an `Attach` inside a
+/// session has a pane's message row and a live editor to leave standing. Both
+/// reach `attachedLoop` with a GREETED client and never with less.
+fn attachRequested(gpa: std.mem.Allocator, g: *Gui, requested: []const u8) !void {
+ const geom = windowCells(g);
+ const outcome = detached_client.attempt(gpa, requested, geom.cols, geom.rows);
+ switch (outcome) {
+ .greeted => |greeted| {
+ var client = greeted;
+ return attachedLoop(gpa, g, &client);
+ },
+ else => {
+ // The window exists but has drawn nothing, so stderr is still the
+ // only place a person is looking; the wording is the message row's,
+ // because it is the same set of outcomes.
+ var mbuf: [256]u8 = undefined;
+ log.err("{s}", .{attachFailure(&mbuf, outcome, requested)});
+ // ...and the exit status keeps the distinction the sentence makes,
+ // for whatever launched this window.
+ return switch (outcome) {
+ .no_session => error.NoSession,
+ .ambiguous => error.AmbiguousSession,
+ .refused => error.Refused,
+ .silent => error.NoGreeting,
+ .lost => |err| err,
+ .greeted => unreachable,
+ };
+ },
+ }
+}
+
+/// The whole of an attached window: input and screen, and nothing else. SDL
+/// events become `pardes.Event`s on the socket through the same `dispatch` a
+/// local session uses; frames come back and go through the same `renderFrame`.
+/// It forks no shell, writes no file and watches no path — the session process
+/// does all of that now — so the only effects still arriving here are the three
+/// that need a human's own display.
+fn attachedLoop(gpa: std.mem.Allocator, g: *Gui, client: *detached_client.Client) !void {
+ // The `bye` is a courtesy: the session survives a frontend that simply
+ // dies, but seven bytes turn "the peer vanished" into "the peer left" in
+ // its log.
+ defer client.detach();
+ // The window may have arrived here from `localSession`, where this was
+ // already called; SDL_StartTextInput is idempotent, and calling it is what
+ // makes the `--attach`-from-startup path receive SDL_EVENT_TEXT_INPUT at
+ // all.
+ _ = c.SDL_StartTextInput(g.window);
+
+ var in: Input = .{ .client = client };
+ // A frame is the only thing that makes this window redraw. There is no
+ // animation clock and no core asking for a tick — the session spends both
+ // and sends the result — so a pass that saw nothing new presents nothing.
+ var dirty = false;
+ var geom = windowCells(g);
+ while (true) {
+ const link = client.wait(detached_client.poll_ms);
+ // DECODE BEFORE REACTING TO THE HANGUP. `wait` reports the close in the
+ // same call that read the last bytes, and the last bytes are the
+ // session's `quit`: `fill` appends every chunk and only then sees the
+ // zero-length read. client.zig prefers POLLIN over POLLHUP for exactly
+ // this reason, and honouring it is what makes an ordinary `Kill` close
+ // every attached window by the front door instead of leaving whichever
+ // one lost the race reporting a broken link.
+ while (true) {
+ const msg = (try client.next()) orelse break;
+ switch (msg) {
+ // A greeting cannot arrive twice and a refusal cannot follow
+ // one at all — `detached_client.attempt` consumed the welcome
+ // before this loop was entered, and the union is exhaustive, so
+ // these two arms exist to say that rather than to do anything.
+ // A session that sent either here is not speaking this protocol.
+ .welcome => {},
+ .refuse => |why| {
+ log.err("session refused an already-greeted frontend: {t}", .{why});
+ return error.Refused;
+ },
+ // Applied too — `grid` and `cursor` are current by the time
+ // this lands, so all that is left is putting them on screen.
+ .frame => dirty = true,
+ // THE SESSION ENDED (`Kill`): every frontend goes with it.
+ .quit => return,
+ // ...and `Detach`: THIS frontend was asked to leave and the
+ // session is carrying on without it, panes and shells and undo
+ // history intact, with whatever other frontends are attached
+ // still looking at it. Leaving because a person asked is a
+ // SUCCESS — hence a plain return and not the `error.Refused`
+ // above — and the deferred `client.detach()` still sends the
+ // `bye`, so the session logs a peer that left rather than one
+ // that vanished. The window closes because `runNative` returns.
+ .detach => return,
+ .set_clipboard => |text| putClipboard(gpa, text),
+ // The answer is not a reply message: it is an ordinary paste
+ // event on the way back, which is the same asynchronous shape
+ // `pull_read_clipboard` already has in process.
+ .read_clipboard => if (takeClipboard()) |text| {
+ defer c.SDL_free(text.ptr);
+ in.post(.{ .paste = text });
+ },
+ .open_link => |url| look.openLink(url),
+ }
+ }
+ try link;
+
+ var sev = std.mem.zeroes(c.SDL_Event);
+ while (c.SDL_PollEvent(&sev)) dispatch(g, &in, &sev);
+ pollGamepad(g, &in);
+ // One check for the whole burst rather than one per event: `Input.post`
+ // stops sending at the first failure, so this is where a dead link is
+ // reported and there is nothing left in flight to lose.
+ if (in.lost) |err| return err;
+ if (in.quit) return;
+
+ // What this WINDOW can show, which is not a promise about the next
+ // frame: with several frontends attached the session grid is the
+ // smallest common one (client.zig GEOMETRY).
+ const now = windowCells(g);
+ if (now.cols != geom.cols or now.rows != geom.rows) {
+ geom = now;
+ try client.resize(now.cols, now.rows);
+ }
+ if (!dirty) continue;
+ dirty = false;
+ paintAttached(g, gpa, client);
+ }
+}
+
+/// The frame the session sent, through the renderer this window already has.
+/// The `Surface` is built OVER the client's grid rather than copied into one:
+/// `renderFrame` reads cells and never writes them, and a full frame of a large
+/// grid is 1.6 MiB.
+///
+/// Three of a session's own surface fields are absent here and each absence is
+/// load-bearing. No panel tracks: a pane transition is composed by the process
+/// that owns the panes and what arrives is the composed result, so `makePaintPlan`
+/// builds its single static batch. No pixel attachments: this wire carries no
+/// images. No previous cells: `hasPanelDiff` is therefore false and the whole
+/// old/new layer machinery stays out of the plan.
+fn paintAttached(g: *Gui, gpa: std.mem.Allocator, client: *detached_client.Client) void {
+ var surface: pardes.Surface = .{
+ .cols = client.cols,
+ .rows = client.rows,
+ .cells = client.grid.items,
+ .cursor = if (client.cursor) |cu| .{ .x = cu.x, .y = cu.y, .bar = cu.bar } else null,
+ };
+ // Two of `renderFrame`'s arguments are chrome colours the session resolved
+ // off a theme that is not on the wire. Both want `chromeTheme().tag_bg`,
+ // and the frame carries it exactly: row zero IS a full-width band that
+ // pardes.zig fills with that colour unconditionally, which is what
+ // `frameChromeBg` reads. The topbar rule then wears the band's own colour,
+ // joining the two bands directly the way
+ // `config.gui_topbar_pane_border_px = 0` does — a rule whose colour we
+ // would have to invent is worse than no rule.
+ const chrome = frameChromeBg(&surface);
+ _ = renderFrame(
+ g,
+ gpa,
+ null,
+ &surface,
+ // No theme background either, so every cell the session left at its
+ // default wears this window's own ground — the same answer a terminal
+ // frontend gives by writing a default cell.
+ null,
+ chrome,
+ chrome,
+ // Crt/Ripple/Glitch are core settings and the core is elsewhere; so is
+ // Debug, which is what the touch overlay hangs off.
+ .{},
+ false,
+ ) catch |err| blk: {
+ log.err("render: {t}", .{err});
+ break :blk false;
+ };
+}
+
+/// The tagline background this frame was painted with, read off the frame. An
+/// attached window has no core to ask for `chromeTheme().tag_bg`, and
+/// `renderFrame` wants it twice: as the chrome band under the topbar's compact
+/// cells, and as the sub-cell strip `buildOverlay` extends below a bottom
+/// tagline band when the window is not a whole number of cells tall.
+///
+/// ROW ZERO is where it is read, and that is not a guess: the topbar is filled
+/// edge to edge with `chrome.tag_bg` at `font_role = .tagline` on every frame
+/// (pardes.zig `renderTopbar`), so its first tagline cell IS the colour. The
+/// last row was the wrong place to look and cost a visibly dark band — a
+/// session whose bottom row is pane BODY has no tagline cell there at all, so
+/// the scan fell through to `bg_default` and painted the topbar's remainder
+/// and every tag-cell gap near-black.
+fn frameChromeBg(surface: *const pardes.Surface) [3]u8 {
+ if (surface.rows == 0 or surface.cols == 0) return bg_default;
+ for (surface.cells[0..surface.cols]) |cell| {
+ if (cell.default or cell.style.font_role != .tagline) continue;
+ return switch (cell.style.bg) {
+ .default => bg_default,
+ .index => |i| palColor(i),
+ .rgb => |rgb| rgb,
+ };
+ }
+ return bg_default;
+}
+
/// PARDES_TEST_GRID=1: headless. No SDL at all — stdin escape sequences in,
/// the rendered Surface out as text frames (same framing as the prototype).
fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void {
@@ -2183,7 +2551,8 @@ fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void {
// The two halves of a pass's input, in the order the flat loop had
// them: the scripted feed, then whatever the reader threads handed
// over. `pump` has no `wait_input` to do it in — see `grid_vtable`.
- const r = try shell.feed.pump(gpa, core, null);
+ var in: Input = .{ .core = core };
+ const r = try shell.feed.pump(gpa, &in, null);
if (r.eof) break;
if (r.n_events != 0) shell.saw_event = true;
shell.drainQueue();
@@ -2222,7 +2591,7 @@ const StdinFeed = struct {
const Result = struct { eof: bool = false, n_events: usize = 0 };
/// Poll stdin briefly and translate what arrived. `g` is null in grid mode.
- fn pump(f: *StdinFeed, gpa: std.mem.Allocator, core: *pardes.Pardes, g: ?*Gui) !Result {
+ fn pump(f: *StdinFeed, gpa: std.mem.Allocator, in: *Input, g: ?*Gui) !Result {
var out: Result = .{};
// a pty stdin also carries the winsize; poll it in place of SIGWINCH
var ws: posix.winsize = std.mem.zeroes(posix.winsize);
@@ -2232,7 +2601,7 @@ const StdinFeed = struct {
{
f.last_cols = ws.col;
f.last_rows = ws.row;
- f.applyResize(core, g, ws.col, ws.row);
+ f.applyResize(in, g, ws.col, ws.row);
out.n_events += 1;
}
@@ -2273,14 +2642,14 @@ const StdinFeed = struct {
if (mapScenePoint(gp, finger.x * win_w, finger.y * win_h, win_w, win_h)) |m| {
finger.x = m.x / win_w;
finger.y = m.y / win_h;
- handleFinger(&gp.touch, core, finger, win_w, win_h, @floatFromInt(gp.cell_w), @floatFromInt(gp.cell_h), @floatFromInt(gp.tagline_width));
+ handleFinger(&gp.touch, in, finger, win_w, win_h, @floatFromInt(gp.cell_w), @floatFromInt(gp.cell_h), @floatFromInt(gp.tagline_width));
} else {
finger.kind = .cancel;
- handleFinger(&gp.touch, core, finger, win_w, win_h, @floatFromInt(gp.cell_w), @floatFromInt(gp.cell_h), @floatFromInt(gp.tagline_width));
- core.update(.pointer_leave);
+ handleFinger(&gp.touch, in, finger, win_w, win_h, @floatFromInt(gp.cell_w), @floatFromInt(gp.cell_h), @floatFromInt(gp.tagline_width));
+ in.post(.pointer_leave);
}
- } else {
- handleFinger(&grid_touch, core, finger, @floatFromInt(core.screen_w), @floatFromInt(core.screen_h), 1, 1, 1);
+ } else if (in.core) |core| {
+ handleFinger(&grid_touch, in, finger, @floatFromInt(core.screen_w), @floatFromInt(core.screen_h), 1, 1, 1);
}
out.n_events += 1;
}
@@ -2305,7 +2674,7 @@ const StdinFeed = struct {
.key_press => |key| {
var text: []const u8 = "";
if (key.text) |t| text = f.cache.put(t);
- core.update(.{ .key = .{
+ in.post(.{ .key = .{
.cp = mapKey(effCp(key)),
.text = text,
.ctrl = key.mods.ctrl,
@@ -2327,7 +2696,7 @@ const StdinFeed = struct {
else => null,
};
if (button) |b| {
- core.update(.{ .mouse = .{
+ in.post(.{ .mouse = .{
.button = b,
.kind = switch (m.type) {
.press => .press,
@@ -2343,11 +2712,11 @@ const StdinFeed = struct {
}
},
.winsize => |vw| {
- f.applyResize(core, g, @intCast(vw.cols), @intCast(vw.rows));
+ f.applyResize(in, g, @intCast(vw.cols), @intCast(vw.rows));
out.n_events += 1;
},
.paste => |text| {
- core.update(.{ .paste = text });
+ in.post(.{ .paste = text });
gpa.free(@constCast(text));
out.n_events += 1;
},
@@ -2357,14 +2726,14 @@ const StdinFeed = struct {
return out;
}
- fn applyResize(f: *StdinFeed, core: *pardes.Pardes, g: ?*Gui, cols: u16, rows: u16) void {
+ fn applyResize(f: *StdinFeed, in: *Input, g: ?*Gui, cols: u16, rows: u16) void {
_ = f;
if (g) |gp| {
// capture mode: resize the window; the frame loop resizes the core
_ = c.SDL_SetWindowSize(gp.window, @intCast(cols * gp.cell_w), @intCast(rows * gp.cell_h));
_ = c.SDL_SyncWindow(gp.window);
} else {
- core.update(.{ .resize = .{ .cols = cols, .rows = rows } });
+ in.post(.{ .resize = .{ .cols = cols, .rows = rows } });
}
}
};
@@ -2459,20 +2828,71 @@ fn dumpGrid(gpa: std.mem.Allocator, surface: *pardes.Surface) !void {
@memcpy(frame[at..][0..footer.len], footer);
at += footer.len;
std.debug.assert(at == frame.len);
- writeFd(1, frame);
+ host_io.writeFd(1, frame);
}
// =====================================================================
// SDL event dispatch
// =====================================================================
-fn dispatch(g: *Gui, core: *pardes.Pardes, sev: *const c.SDL_Event) void {
+/// Where a translated SDL event goes, and the only thing the input path knows
+/// about the session it belongs to. The local shell hands events to the
+/// `Pardes` in this process; an attached window puts them on a socket, because
+/// the core is in the detached one. Everything between an SDL_Event and a
+/// `pardes.Event` — the keycode table, the pointer/cell mapping, the touch
+/// machine, the Steam Deck mapping — is ONE translation serving both, and this
+/// is what keeps it from becoming two.
+const Input = struct {
+ /// Null in an attached window, and this is also the flag the renderer- and
+ /// pointer-side functions test: no core means no pane rects and no theme,
+ /// and each of those has a documented body-grid fallback.
+ core: ?*pardes.Pardes = null,
+ /// Null in a local session. Exactly one of the two is ever set.
+ client: ?*detached_client.Client = null,
+ /// Attached only: the window was closed. A local session says the same
+ /// thing by writing `core.quit`, which the core owns and this must not
+ /// shadow.
+ quit: bool = false,
+ /// Attached only: a send failed, which means this window has lost its
+ /// session. Recorded rather than returned because `dispatch` is called from
+ /// inside an SDL drain with no error path, and a dead link does not need
+ /// reporting once per event in the burst.
+ lost: ?anyerror = null,
+
+ /// One translated event on its way to the core, wherever the core is.
+ fn post(in: *Input, ev: pardes.Event) void {
+ if (in.core) |core| return core.update(ev);
+ const client = in.client orelse return;
+ // Nothing more goes out after the first failure: the rest of this
+ // burst would each fail the same way, and the loop is about to leave.
+ if (in.lost != null) return;
+ client.send(.{ .event = ev }) catch |err| switch (err) {
+ // A message this protocol cannot carry is not a link that has
+ // died. The one event here that can reach `wire.max_payload` is a
+ // paste of a 16 MiB clipboard, and dropping it beats ending a
+ // session over it.
+ error.Overlong, error.NoSpace => {},
+ else => in.lost = err,
+ };
+ }
+
+ /// The window asked to close. In a session that ends the session; in an
+ /// attached window it ends this frontend and nothing else — the panes, the
+ /// shells and the undo history are in the other process and outlive it,
+ /// which is the whole point of `--detach`.
+ fn close(in: *Input) void {
+ if (in.core) |core| core.quit = true;
+ in.quit = true;
+ }
+};
+
+fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void {
switch (sev.type) {
- c.SDL_EVENT_QUIT, c.SDL_EVENT_WINDOW_CLOSE_REQUESTED => core.quit = true,
+ c.SDL_EVENT_QUIT, c.SDL_EVENT_WINDOW_CLOSE_REQUESTED => in.close(),
c.SDL_EVENT_WINDOW_MOUSE_LEAVE => {
g.pointer_present = false;
g.pointer_mapped = false;
- core.update(.pointer_leave);
+ in.post(.pointer_leave);
},
// window resizes are picked up by the per-frame grid check
c.SDL_EVENT_KEY_DOWN => {
@@ -2480,8 +2900,9 @@ fn dispatch(g: *Gui, core: *pardes.Pardes, sev: *const c.SDL_Event) void {
g.live_alt = (sev.key.mod & c.SDL_KMOD_ALT) != 0;
// Ctrl+ / Ctrl-: the font size. Here rather than in keyDown
// because it is the shell's business and not the core's — the
- // core has no font — and because this is the only side of the
- // wall where `g` is in scope anyway.
+ // core has no font, and an attached window has no core at all yet
+ // still resizes its own text — and because this is the only side
+ // of the wall where `g` is in scope anyway.
//
// SIX keycodes for two keys, and every one of them is a key
// somebody actually presses:
@@ -2520,12 +2941,15 @@ fn dispatch(g: *Gui, core: *pardes.Pardes, sev: *const c.SDL_Event) void {
// the size already on screen
if (want != g.px) {
g.px = want;
- refitFont(g, core);
- observeGuiFont(g, core);
+ refitFont(g, in.core);
+ // Attached, the new grid reaches the session as the
+ // ordinary window-geometry check on the next pass, and
+ // there is no local Font state to observe either.
+ if (in.core) |core| observeGuiFont(g, core);
}
return;
}
- keyDown(core, sev.key.key, sev.key.mod);
+ keyDown(in, sev.key.key, sev.key.mod);
},
c.SDL_EVENT_KEY_UP => {
g.live_ctrl = (sev.key.mod & c.SDL_KMOD_CTRL) != 0;
@@ -2548,7 +2972,7 @@ fn dispatch(g: *Gui, core: *pardes.Pardes, sev: *const c.SDL_Event) void {
var it = view.iterator();
var at: usize = 0;
while (it.nextCodepoint()) |cp| : (at = it.i) {
- core.update(.{ .key = .{ .cp = cp, .text = text[at..it.i], .ctrl = g.live_ctrl, .alt = g.live_alt } });
+ in.post(.{ .key = .{ .cp = cp, .text = text[at..it.i], .ctrl = g.live_ctrl, .alt = g.live_alt } });
}
},
c.SDL_EVENT_MOUSE_BUTTON_DOWN, c.SDL_EVENT_MOUSE_BUTTON_UP => {
@@ -2562,22 +2986,22 @@ fn dispatch(g: *Gui, core: *pardes.Pardes, sev: *const c.SDL_Event) void {
g.pad_x = b.x;
g.pad_y = b.y;
g.pointer_present = true;
- const mc = mouseCell(g, core, b.x, b.y) orelse {
+ const mc = mouseCell(g, in.core, b.x, b.y) orelse {
g.pointer_mapped = false;
// A release outside the visible CRT tube still ends a drag at
// its last real cell; a press on black margin is inert.
- if (!b.down) if (g.pointer_cell) |last| core.update(.{ .mouse = .{
+ if (!b.down) if (g.pointer_cell) |last| in.post(.{ .mouse = .{
.button = button,
.kind = .release,
.col = last.col,
.row = last.row,
} });
- core.update(.pointer_leave);
+ in.post(.pointer_leave);
return;
};
g.pointer_mapped = true;
g.pointer_cell = mc;
- core.update(.{
+ in.post(.{
.mouse = .{
.button = button,
.kind = if (b.down) .press else .release,
@@ -2605,14 +3029,14 @@ fn dispatch(g: *Gui, core: *pardes.Pardes, sev: *const c.SDL_Event) void {
.right
else
null;
- const mc = mouseCell(g, core, m.x, m.y) orelse {
+ const mc = mouseCell(g, in.core, m.x, m.y) orelse {
g.pointer_mapped = false;
- core.update(.pointer_leave);
+ in.post(.pointer_leave);
return;
};
g.pointer_mapped = true;
g.pointer_cell = mc;
- core.update(.{ .mouse = .{
+ in.post(.{ .mouse = .{
.button = held orelse .none,
.kind = if (held != null) .drag else .motion,
.col = mc.col,
@@ -2625,53 +3049,68 @@ fn dispatch(g: *Gui, core: *pardes.Pardes, sev: *const c.SDL_Event) void {
g.pad_x = w.mouse_x;
g.pad_y = w.mouse_y;
g.pointer_present = true;
- const mc = mouseCell(g, core, w.mouse_x, w.mouse_y) orelse {
+ const mc = mouseCell(g, in.core, w.mouse_x, w.mouse_y) orelse {
g.pointer_mapped = false;
- core.update(.pointer_leave);
+ in.post(.pointer_leave);
return;
};
g.pointer_mapped = true;
g.pointer_cell = mc;
if (w.y != 0 and std.math.isFinite(w.y)) {
- // Preserve SDL's floating-point distance. Input events drained
- // in this loop naturally form one render batch; stepScroll
- // applies their exact sum and tells the core only about whole
- // row boundaries. One accumulator belongs to one pane, so a
- // wheel event over another pane first retires the old offset.
- 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) {
- // There is one fractional overlay, not one per pane. Retire
- // the old one at its already boundary-rounded core state;
- // carrying its lag into `hit` would move the wrong pane.
- 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) {
- // PDF placements live in physical document space, so
- // preserve SDL's raw magnitude directly instead of
- // quantizing through synthetic wheel buttons/rows.
+ if (in.core) |core| {
+ // Preserve SDL's floating-point distance. Input events drained
+ // in this loop naturally form one render batch; stepScroll
+ // applies their exact sum and tells the core only about whole
+ // row boundaries. One accumulator belongs to one pane, so a
+ // wheel event over another pane first retires the old offset.
+ 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) {
+ // There is one fractional overlay, not one per pane. Retire
+ // the old one at its already boundary-rounded core state;
+ // carrying its lag into `hit` would move the wrong pane.
resetScroll(g);
- core.update(.{ .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;
- g.scroll_delta = accumulateWheelDelta(g.scroll_delta, w.y);
+ };
+ 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) {
+ // PDF placements live in physical document space, so
+ // preserve SDL's raw magnitude directly instead of
+ // quantizing through synthetic wheel buttons/rows.
+ 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;
+ g.scroll_delta = accumulateWheelDelta(g.scroll_delta, w.y);
+ }
}
+ } else {
+ // ATTACHED: no pane rect ever reaches this window, so there
+ // is nothing to slide a fractional row against — the
+ // session owns the panes and composes what is painted here.
+ // Accumulate SDL's exact distance (a precision touchpad
+ // sends fractions of a row) in the same field `stepScroll`
+ // would have drained, and hand the session the whole rows,
+ // which is all `Event.mouse` has ever been able to say.
+ 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 } });
+ while (g.scroll_delta <= -1) : (g.scroll_delta += 1)
+ in.post(.{ .mouse = .{ .button = .wheel_up, .kind = .press, .col = mc.col, .row = mc.row } });
}
}
- if (w.x != 0) core.update(.{ .mouse = .{
+ if (w.x != 0) in.post(.{ .mouse = .{
.button = if (w.x > 0) .wheel_right else .wheel_left,
.kind = .press,
.col = mc.col,
@@ -2702,14 +3141,14 @@ fn dispatch(g: *Gui, core: *pardes.Pardes, sev: *const c.SDL_Event) void {
if (mapScenePoint(g, finger.x * win_w, finger.y * win_h, win_w, win_h)) |m| {
finger.x = m.x / win_w;
finger.y = m.y / win_h;
- handleFinger(&g.touch, core, finger, win_w, win_h, fcw, fch, @floatFromInt(g.tagline_width));
+ handleFinger(&g.touch, in, finger, win_w, win_h, fcw, fch, @floatFromInt(g.tagline_width));
} else {
finger.kind = .cancel;
- handleFinger(&g.touch, core, finger, win_w, win_h, fcw, fch, @floatFromInt(g.tagline_width));
- core.update(.pointer_leave);
+ handleFinger(&g.touch, in, finger, win_w, win_h, fcw, fch, @floatFromInt(g.tagline_width));
+ in.post(.pointer_leave);
}
},
- c.SDL_EVENT_PINCH_BEGIN, c.SDL_EVENT_PINCH_UPDATE => core.update(.{ .pinch = sev.pinch.scale }),
+ c.SDL_EVENT_PINCH_BEGIN, c.SDL_EVENT_PINCH_UPDATE => in.post(.{ .pinch = sev.pinch.scale }),
// ---- steamdeck: first gamepad drives a virtual mouse (buttons here,
// axes polled per frame in pollGamepad) ----
c.SDL_EVENT_GAMEPAD_ADDED => {
@@ -2730,7 +3169,9 @@ fn dispatch(g: *Gui, core: *pardes.Pardes, sev: *const c.SDL_Event) void {
c.SDL_EVENT_GAMEPAD_TOUCHPAD_MOTION,
c.SDL_EVENT_GAMEPAD_TOUCHPAD_UP,
=> {
- const in: deck.Input = switch (sev.type) {
+ // `pad_input` and not `in`: this file's `Input` is the event sink
+ // above, and deck.Input is a controller reading.
+ const pad_input: deck.Input = switch (sev.type) {
c.SDL_EVENT_GAMEPAD_BUTTON_DOWN, c.SDL_EVENT_GAMEPAD_BUTTON_UP => .{ .button = .{
.idx = sev.gbutton.button,
.down = sev.gbutton.down,
@@ -2752,10 +3193,10 @@ fn dispatch(g: *Gui, core: *pardes.Pardes, sev: *const c.SDL_Event) void {
} },
};
var acts: [3]deck.Action = undefined;
- for (acts[0..g.deck.feed(in, &acts)]) |act| switch (act) {
+ for (acts[0..g.deck.feed(pad_input, &acts)]) |act| switch (act) {
.move => |mv| {
const geometry = windowGeometry(g.window);
- const old = mouseCellWithGeometry(g, core, g.pad_x, g.pad_y, geometry);
+ const old = mouseCellWithGeometry(g, in.core, g.pad_x, g.pad_y, geometry);
// deck.Action.move is in physical screen pixels. Keep the
// stored/warped cursor in SDL window coordinates.
const dx = mv.dx * geometry.window_w / geometry.pixel_w;
@@ -2766,9 +3207,9 @@ fn dispatch(g: *Gui, core: *pardes.Pardes, sev: *const c.SDL_Event) void {
// cursor and a hardware mouse are one visible pointer
c.SDL_WarpMouseInWindow(g.window, g.pad_x, g.pad_y);
g.pointer_present = true;
- const mc = mouseCellWithGeometry(g, core, g.pad_x, g.pad_y, geometry) orelse {
+ const mc = mouseCellWithGeometry(g, in.core, g.pad_x, g.pad_y, geometry) orelse {
g.pointer_mapped = false;
- core.update(.pointer_leave);
+ in.post(.pointer_leave);
continue;
};
g.pointer_mapped = true;
@@ -2778,7 +3219,7 @@ fn dispatch(g: *Gui, core: *pardes.Pardes, sev: *const c.SDL_Event) void {
// moving with a click held drags, so selections stretch
// (a firm right-pad press drags-selects like a laptop pad)
const held = heldPointerButton(g);
- core.update(.{ .mouse = .{
+ in.post(.{ .mouse = .{
.button = held orelse .none,
.kind = if (held != null) .drag else .motion,
.col = mc.col,
@@ -2792,23 +3233,23 @@ fn dispatch(g: *Gui, core: *pardes.Pardes, sev: *const c.SDL_Event) void {
.look => .right,
};
g.pointer_present = true;
- const mc = mouseCell(g, core, g.pad_x, g.pad_y) orelse {
+ const mc = mouseCell(g, in.core, g.pad_x, g.pad_y) orelse {
g.pointer_mapped = false;
// Mirror hardware mouse releases: black CRT margins
// are inert for presses, but cannot strand a drag whose
// button was pressed over the visible tube.
- if (!ck.down) if (g.pointer_cell) |last| core.update(.{ .mouse = .{
+ if (!ck.down) if (g.pointer_cell) |last| in.post(.{ .mouse = .{
.button = button,
.kind = .release,
.col = last.col,
.row = last.row,
} });
- core.update(.pointer_leave);
+ in.post(.pointer_leave);
continue;
};
g.pointer_mapped = true;
g.pointer_cell = mc;
- core.update(.{ .mouse = .{
+ in.post(.{ .mouse = .{
.button = button,
.kind = if (ck.down) .press else .release,
.col = mc.col,
@@ -2817,9 +3258,9 @@ fn dispatch(g: *Gui, core: *pardes.Pardes, sev: *const c.SDL_Event) void {
},
.wheel => |ticks| {
g.pointer_present = true;
- const mc = mouseCell(g, core, g.pad_x, g.pad_y) orelse {
+ const mc = mouseCell(g, in.core, g.pad_x, g.pad_y) orelse {
g.pointer_mapped = false;
- core.update(.pointer_leave);
+ in.post(.pointer_leave);
continue;
};
g.pointer_mapped = true;
@@ -2827,7 +3268,7 @@ fn dispatch(g: *Gui, core: *pardes.Pardes, sev: *const c.SDL_Event) void {
var left = ticks;
while (left != 0) {
left += if (ticks > 0) -1 else 1;
- core.update(.{ .mouse = .{
+ in.post(.{ .mouse = .{
.button = if (ticks > 0) .wheel_down else .wheel_up,
.kind = .press,
.col = mc.col,
@@ -2835,14 +3276,23 @@ fn dispatch(g: *Gui, core: *pardes.Pardes, sev: *const c.SDL_Event) void {
} });
}
},
- .key => |k| core.update(.{
+ .key => |k| in.post(.{
.key = switch (k) {
.n => .{ .cp = 'n', .text = "n" },
.cap_n => .{ .cp = 'N', .text = "N" },
.enter => .{ .cp = pardes.Key.enter },
.tab => .{ .cp = pardes.Key.tab },
- // back paddle: Ctrl-<configured toggle key> flips tty mode
- .tty_toggle => .{ .cp = core.opts.tty_toggle, .ctrl = true },
+ // back paddle: flip tty mode. `--tty-toggle` moves the
+ // ctrl chord and is the SESSION's option, so an
+ // attached window — which cannot know it and has no
+ // core to ask — sends the spelling that cannot be
+ // reconfigured instead: `config.tty_toggle_alt`, which
+ // pardes.zig honours beside the ctrl chord for exactly
+ // the hosts that can express it.
+ .tty_toggle => if (in.core) |core|
+ .{ .cp = core.opts.tty_toggle, .ctrl = true }
+ else
+ .{ .cp = config.tty_toggle_alt[0].cp, .shift = true },
},
}),
// a brief gentle ack for execute/look, not a buzz
@@ -2857,7 +3307,7 @@ fn dispatch(g: *Gui, core: *pardes.Pardes, sev: *const c.SDL_Event) void {
/// Special keys + ctrl/alt shortcuts. Plain printable keys arrive as
/// SDL_EVENT_TEXT_INPUT instead (so shift/layout map correctly).
-fn keyDown(core: *pardes.Pardes, sym: c.SDL_Keycode, mod: c.SDL_Keymod) void {
+fn keyDown(in: *Input, sym: c.SDL_Keycode, mod: c.SDL_Keymod) void {
const ctrl = (mod & c.SDL_KMOD_CTRL) != 0;
const alt = (mod & c.SDL_KMOD_ALT) != 0;
const shift = (mod & c.SDL_KMOD_SHIFT) != 0;
@@ -2885,7 +3335,7 @@ fn keyDown(core: *pardes.Pardes, sym: c.SDL_Keycode, mod: c.SDL_Keymod) void {
},
};
if (cp == 0) return;
- core.update(.{ .key = .{ .cp = cp, .ctrl = ctrl, .alt = alt, .shift = shift } });
+ in.post(.{ .key = .{ .cp = cp, .ctrl = ctrl, .alt = alt, .shift = shift } });
}
fn pixelCell(px: f32, cell: u32) u16 {
@@ -2893,8 +3343,13 @@ fn pixelCell(px: f32, cell: u32) u16 {
return @intFromFloat(@min(idx, 10_000));
}
+/// Which grid cell a physical point is in. `core` is null in an attached
+/// window, and then the pane loop is skipped and the body grid answers — the
+/// same fallback `taglineLayoutForCell` takes for the same missing fact, which
+/// is what makes a click on an attached tagline land on the glyph it was aimed
+/// at.
fn gridCellAtDimensions(
- core: *const pardes.Pardes,
+ core: ?*const pardes.Pardes,
x: f32,
y: f32,
body_w: f32,
@@ -2908,21 +3363,21 @@ fn gridCellAtDimensions(
if (row < pardes.TOPBAR_H)
return .{ .col = @intFromFloat(@min(@floor(@max(x, 0) / tag_w), 10_000)), .row = row };
- for (core.panes, 0..) |slot, id| {
+ if (core) |p| for (p.panes, 0..) |slot, id| {
if (slot == null) continue;
- const r = core.rects[id];
- const tag_y = if (core.settings.tag_bottom) r.y + r.h -| pardes.BOX_H else r.y;
+ const r = p.rects[id];
+ const tag_y = if (p.settings.tag_bottom) r.y + r.h -| pardes.BOX_H else r.y;
if (row != tag_y or r.w == 0) continue;
const left = @as(f32, @floatFromInt(r.x)) * safe_body_w;
const right = @as(f32, @floatFromInt(r.x + r.w)) * safe_body_w;
if (x < left or x >= right) continue;
const within: u16 = @intFromFloat(@min(@floor(@max(0, x - left) / tag_w), @as(f32, @floatFromInt(r.w - 1))));
return .{ .col = r.x + within, .row = row };
- }
+ };
return .{ .col = @intFromFloat(@min(@floor(@max(x, 0) / safe_body_w), 10_000)), .row = row };
}
-fn gridCellAtPixels(g: *const Gui, core: *const pardes.Pardes, x: f32, y: f32) MouseCell {
+fn gridCellAtPixels(g: *const Gui, core: ?*const pardes.Pardes, x: f32, y: f32) MouseCell {
return gridCellAtDimensions(
core,
x,
@@ -2961,7 +3416,7 @@ fn mapScenePoint(g: *const Gui, x: f32, y: f32, w: f32, h: f32) ?crt.Point {
return crt.mapScene(x, y, w, h, g.presented_scene.effects, g.presented_scene.time_seconds);
}
-fn mouseCellWithGeometry(g: *const Gui, core: *const pardes.Pardes, x: f32, y: f32, geometry: WindowGeometry) ?MouseCell {
+fn mouseCellWithGeometry(g: *const Gui, core: ?*const pardes.Pardes, x: f32, y: f32, geometry: WindowGeometry) ?MouseCell {
const physical = windowPointToPixels(geometry, x, y);
const mapped = mapScenePoint(g, physical.x, physical.y, geometry.pixel_w, geometry.pixel_h) orelse return null;
return gridCellAtPixels(g, core, mapped.x, mapped.y);
@@ -2969,7 +3424,7 @@ fn mouseCellWithGeometry(g: *const Gui, core: *const pardes.Pardes, x: f32, y: f
/// SDL window coords → the scene cell displayed at that physical point.
/// Mouse, touch and the Deck pointer all share the same CRT/ripple/glitch map.
-fn mouseCell(g: *const Gui, core: *const pardes.Pardes, x: f32, y: f32) ?MouseCell {
+fn mouseCell(g: *const Gui, core: ?*const pardes.Pardes, x: f32, y: f32) ?MouseCell {
return mouseCellWithGeometry(g, core, x, y, windowGeometry(g.window));
}
@@ -3009,14 +3464,14 @@ fn refreshPresentedPointer(g: *Gui, core: *pardes.Pardes) void {
/// RIGHT stick moves the virtual cursor at ~cell granularity (emits
/// button-less motion so hover works), LEFT stick accumulates into wheel
/// ticks (both axes: vertical + horizontal) at the cursor position.
-fn pollGamepad(g: *Gui, core: *pardes.Pardes) void {
+fn pollGamepad(g: *Gui, in: *Input) void {
const pad = g.gamepad orelse return;
const geometry = windowGeometry(g.window);
const deadzone: f32 = 8000;
// right stick = pointer
const ax: f32 = @floatFromInt(c.SDL_GetGamepadAxis(pad, c.SDL_GAMEPAD_AXIS_RIGHTX));
const ay: f32 = @floatFromInt(c.SDL_GetGamepadAxis(pad, c.SDL_GAMEPAD_AXIS_RIGHTY));
- const old = mouseCellWithGeometry(g, core, g.pad_x, g.pad_y, geometry);
+ const old = mouseCellWithGeometry(g, in.core, g.pad_x, g.pad_y, geometry);
// full tilt ≈ 0.4 cell-heights per frame: a gentle, aimable glide (the
// mouse-move sensitivity knob — raise for a faster pointer)
const speed: f32 = @as(f32, @floatFromInt(g.cell_h)) * 0.4;
@@ -3031,8 +3486,8 @@ fn pollGamepad(g: *Gui, core: *pardes.Pardes) void {
g.pointer_present = true;
c.SDL_WarpMouseInWindow(g.window, g.pad_x, g.pad_y);
}
- const mc = mouseCellWithGeometry(g, core, g.pad_x, g.pad_y, geometry) orelse {
- if (g.pointer_present and g.pointer_mapped) core.update(.pointer_leave);
+ const mc = mouseCellWithGeometry(g, in.core, g.pad_x, g.pad_y, geometry) orelse {
+ if (g.pointer_present and g.pointer_mapped) in.post(.pointer_leave);
if (g.pointer_present) g.pointer_mapped = false;
return;
};
@@ -3041,7 +3496,7 @@ fn pollGamepad(g: *Gui, core: *pardes.Pardes) void {
g.pointer_cell = mc;
if (old == null or mc.col != old.?.col or mc.row != old.?.row) {
const held = heldPointerButton(g);
- core.update(.{ .mouse = .{
+ in.post(.{ .mouse = .{
.button = held orelse .none,
.kind = if (held != null) .drag else .motion,
.col = mc.col,
@@ -3057,12 +3512,12 @@ fn pollGamepad(g: *Gui, core: *pardes.Pardes) void {
while (@abs(g.pad_scroll) >= 1.0) {
const button: pardes.Mouse.Button = if (g.pad_scroll < 0) .wheel_up else .wheel_down;
g.pad_scroll += if (g.pad_scroll < 0) 1.0 else -1.0;
- core.update(.{ .mouse = .{ .button = button, .kind = .press, .col = mc.col, .row = mc.row } });
+ in.post(.{ .mouse = .{ .button = button, .kind = .press, .col = mc.col, .row = mc.row } });
}
while (@abs(g.pad_scroll_h) >= 1.0) {
const button: pardes.Mouse.Button = if (g.pad_scroll_h < 0) .wheel_left else .wheel_right;
g.pad_scroll_h += if (g.pad_scroll_h < 0) 1.0 else -1.0;
- core.update(.{ .mouse = .{ .button = button, .kind = .press, .col = mc.col, .row = mc.row } });
+ in.post(.{ .mouse = .{ .button = button, .kind = .press, .col = mc.col, .row = mc.row } });
}
}
@@ -3237,19 +3692,23 @@ fn shellOf(ctx: ?*anyopaque) *Shell {
fn waitInput(ctx: ?*anyopaque, timeout_ms: u32) void {
const s = shellOf(ctx);
const core = s.core;
+ // The one place a local session builds the sink: everything downstream of
+ // here — `dispatch`, the scripted feed, the sticks — is the same code an
+ // attached window runs with `client` set instead.
+ var in: Input = .{ .core = core };
if (s.gui) |g| {
var sev = std.mem.zeroes(c.SDL_Event);
const ms: c_int = if (timeout_ms != 0) @intCast(timeout_ms) else 16;
if (c.SDL_WaitEventTimeout(&sev, ms)) {
- dispatch(g, core, &sev);
- while (c.SDL_PollEvent(&sev)) dispatch(g, core, &sev);
+ dispatch(g, &in, &sev);
+ while (c.SDL_PollEvent(&sev)) dispatch(g, &in, &sev);
}
}
if (s.test_mode) {
// A dead scripted feed ends the session HERE, before the inbox, the
// sticks and the frame: the pre-pump loop broke at this line, and a
// capture written after EOF is a frame no script asked for.
- const r = s.feed.pump(s.gpa, core, s.gui) catch {
+ const r = s.feed.pump(s.gpa, &in, s.gui) catch {
core.quit = true;
return;
};
@@ -3259,7 +3718,7 @@ fn waitInput(ctx: ?*anyopaque, timeout_ms: u32) void {
}
}
s.drainQueue();
- if (s.gui) |g| pollGamepad(g, core);
+ if (s.gui) |g| pollGamepad(g, &in);
}
/// Per-frame host bookkeeping with no event of its own, in the order the flat
@@ -3307,12 +3766,8 @@ fn pollFrame(ctx: ?*anyopaque) void {
pollCwds(core, s.ptys);
// Off g.cell_w/h, not the startup metrics: a font change moves them, and
// this is the line that would go on dividing by the old cell.
- var pw: c_int = 0;
- var ph: c_int = 0;
- _ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph);
- const cols: u16 = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(pw, 1))), g.cell_w)));
- const rows: u16 = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(ph, 1))), g.cell_h)));
- if (updateCoreResize(core, cols, rows, g.cell_w, g.cell_h)) resetScroll(g);
+ const geom = windowCells(g);
+ if (updateCoreResize(core, geom.cols, geom.rows, g.cell_w, g.cell_h)) resetScroll(g);
stepScroll(g, core, s.gpa);
}
@@ -3435,7 +3890,12 @@ fn spawnPane(ctx: ?*anyopaque, pane: u8, cwd: []const u8) void {
cwd_buf[cwd.len] = 0;
cwd_z = @ptrCast(&cwd_buf);
}
- const pt = forkShell(s.core, pane, s.prompt_rcs, s.core.shellBin(), cwd_z, s.core.screen_h, s.core.screen_w, s.fs);
+ // The machine-local half is host_io.zig's, not this file's: the same fork
+ // the tty shell and the detached daemon do, including the CLOEXEC on the
+ // master that this copy used to be missing (a master a later shell inherits
+ // is never closed, so a deleted pane's shell never hangs up).
+ const child = host_io.forkShell(s.core, pane, s.prompt_rcs, s.core.shellBin(), cwd_z, s.core.screen_h, s.core.screen_w, s.fs);
+ const pt: Pty = .{ .fd = child.file.handle, .pid = child.pid };
s.ptys[pane] = pt;
// report the pane's starting directory back to the core (tags); the slot
// needs no occupancy reset, nothing about it is remembered
@@ -3446,7 +3906,7 @@ fn spawnPane(ctx: ?*anyopaque, pane: u8, cwd: []const u8) void {
fn ptyWrite(ctx: ?*anyopaque, pane: u8, bytes: []const u8) void {
const s = shellOf(ctx);
- if (s.ptys[pane]) |pt| writeFd(pt.fd, bytes);
+ if (s.ptys[pane]) |pt| host_io.writeFd(pt.fd, bytes);
}
fn ptyResize(ctx: ?*anyopaque, pane: u8, cols: u16, rows: u16) void {
@@ -3467,21 +3927,9 @@ fn ttyTaken(ctx: ?*anyopaque, pane: u8) bool {
return look.ttyTaken(pt.pid, pt.fd);
}
-fn writeWholeFile(path: []const u8, bytes: []const u8) bool {
- var pathbuf: [4096:0]u8 = undefined;
- if (path.len >= pathbuf.len) return false;
- @memcpy(pathbuf[0..path.len], path);
- pathbuf[path.len] = 0;
- const fd = libc.open(pathbuf[0..path.len :0], .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644));
- if (fd < 0) return false;
- writeFd(fd, bytes);
- _ = libc.close(fd);
- return true;
-}
-
fn writeFile(ctx: ?*anyopaque, pane: u8, path: []const u8, bytes: []const u8) void {
const s = shellOf(ctx);
- if (!writeWholeFile(path, bytes)) return;
+ if (!host_io.writeFileBytes(path, bytes)) return;
// our own write is about to come back as a watch event: restamp from the
// bytes we just put there so it reads as "no change". Only for the pane's
// OWN file — a `Put` elsewhere is a change like any other.
@@ -3502,7 +3950,7 @@ fn writeDump(ctx: ?*anyopaque, bytes: []const u8) void {
const s = shellOf(ctx);
var pbuf: [1024:0]u8 = undefined;
const path = pardes.dump.outPath(&pbuf) orelse return;
- if (!writeWholeFile(path, bytes)) return;
+ if (!host_io.writeFileBytes(path, bytes)) return;
s.core.setLastDump(path);
}
@@ -3538,12 +3986,35 @@ fn dumpThemes(ctx: ?*anyopaque, pane: u8) void {
s.core.setMessage(pane, message.stamp(&mbuf, "dumped themes", out_dir));
}
+/// Put `text` on THIS display's clipboard. The one place that copy happens:
+/// SDL wants a sentinel-terminated string and a run of core cells is not one.
+/// Shared, because the in-process host and an attached window answering a
+/// `set_clipboard` off the wire are the same desktop action.
+fn putClipboard(gpa: std.mem.Allocator, text: []const u8) void {
+ const z = gpa.dupeZ(u8, text) catch return;
+ defer gpa.free(z);
+ _ = c.SDL_SetClipboardText(z.ptr);
+}
+
+/// THIS display's clipboard, or null when it holds nothing. SDL3 hands over an
+/// OWNED copy that is the caller's to `SDL_free`, and reports "no text" as an
+/// EMPTY string rather than null — so the length check is what actually
+/// rejects a miss. Shared with the attached loop for the `putClipboard`
+/// reason, turned round.
+fn takeClipboard() ?[:0]u8 {
+ const raw = c.SDL_GetClipboardText() orelse return null;
+ const text = std.mem.span(raw);
+ if (text.len == 0) {
+ c.SDL_free(raw);
+ return null;
+ }
+ return text;
+}
+
fn setClipboard(ctx: ?*anyopaque, text: []const u8) void {
const s = shellOf(ctx);
if (s.gui == null) return;
- const z = s.gpa.dupeZ(u8, text) catch return;
- defer s.gpa.free(z);
- _ = c.SDL_SetClipboardText(z.ptr);
+ putClipboard(s.gpa, text);
}
fn readClipboard(ctx: ?*anyopaque) void {
@@ -3551,13 +4022,8 @@ fn readClipboard(ctx: ?*anyopaque) void {
if (s.gui == null) return;
// SDL answers synchronously, so the paste the core is waiting on lands
// inside this same drain — nothing to remember, no reply path to plumb.
- // SDL3 hands over an OWNED copy that is ours to SDL_free, and reports "no
- // text" as an EMPTY string rather than null, so the length check is what
- // actually rejects a miss.
- const raw = c.SDL_GetClipboardText() orelse return;
- defer c.SDL_free(raw);
- const text = std.mem.span(raw);
- if (text.len == 0) return;
+ const text = takeClipboard() orelse return;
+ defer c.SDL_free(text.ptr);
s.core.update(.{ .paste = text });
}
@@ -3576,27 +4042,6 @@ fn pipe(ctx: ?*anyopaque, id: u32) void {
if (s.threads_ok) spawnPipe(s.core, s.io, s.gpa, s.queue, s.pipe_tasks, id);
}
-fn forkShell(core: *pardes.Pardes, pane: usize, prompt_rcs: *const shell_bin.PromptRcs, bin: []const u8, cwd: ?[*:0]const u8, rows: u16, cols: u16, fs: ?*const fuse.Fs) Pty {
- var master: c_int = undefined;
- // resolved BEFORE the fork, into this frame, which the child inherits:
- // nothing between fork and exec may allocate, and a PATH search would
- var path_buf: [std.fs.max_path_bytes]u8 = undefined;
- const spawn = shell_bin.resolve(bin, &path_buf, prompt_rcs);
- // ...and so is the pane's own address on the control filesystem: acme puts
- // `winid` in the child, which is safe there only because rfork(RFENVG) has
- // just given it a private environment group. See fs_service.exportPaneEnv.
- fs_service.exportPaneEnv(fs, if (core.panes[pane]) |pn| pn.serial else 0);
- const ws = posix.winsize{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 };
- const pid = forkpty(&master, null, null, &ws);
- if (pid == 0) {
- if (cwd) |cd| _ = chdir(cd);
- _ = execv(spawn.path, &spawn.argv);
- _exit(127);
- }
- if (pid > 0) core.acknowledgeShell(pane, std.mem.span(spawn.path), spawn.argv[1] != null);
- return .{ .fd = master, .pid = pid };
-}
-
/// Live cwd for tags/look: a cheap per-pane process lookup, polled every frame
/// because a tagline draws it. Whether a pane's tty still belongs to the prompt
/// pardes forked is deliberately NOT polled with it — see `ttyTaken`.
@@ -3817,7 +4262,7 @@ fn emitScrollRows(g: *Gui, instances: [*]CellInstance, base: u32, surface: *pard
var col = x0;
while (col < x0 + bw) : (col += 1) {
const sidx: u32 = @as(u32, row) * surface.cols + col;
- emitInstance(g, instances, base + n, col, row, shifted, win_w, win_h, null, surface.at(col, row), false, sidx == cursor_idx and !cursor_bar, page);
+ emitInstance(g, instances, base + n, col, row, shifted, win_w, win_h, null, cellFontRole(surface.at(col, row)), surface.at(col, row), false, sidx == cursor_idx and !cursor_bar, page);
n += 1;
}
}
@@ -3827,7 +4272,7 @@ fn emitScrollRows(g: *Gui, instances: [*]CellInstance, base: u32, surface: *pard
const erow: u16 = if (g.scroll_lag > 0) y0 + bh else y0 - 1;
var i: u16 = 0;
while (i < bw) : (i += 1) {
- emitInstance(g, instances, base + n, x0 + i, erow, shifted, win_w, win_h, null, &g.scroll_edge[i], false, false, page);
+ emitInstance(g, instances, base + n, x0 + i, erow, shifted, win_w, win_h, null, cellFontRole(&g.scroll_edge[i]), &g.scroll_edge[i], false, false, page);
n += 1;
}
}
@@ -4318,7 +4763,7 @@ fn drawNativeImagesGpu(
fn renderFrame(
g: *Gui,
gpa: std.mem.Allocator,
- core: *pardes.Pardes,
+ core: ?*const pardes.Pardes,
surface: *pardes.Surface,
theme_bg: ?[3]u8,
topbar_pane_border_rgb: [3]u8,
@@ -4458,9 +4903,9 @@ fn renderFrame(
const data_diff = data_effect and surface.panelCellChanged(col, row);
const logical_idx: u32 = @as(u32, row) * surface.cols + col;
const instance_count = if (data_diff)
- cellInstanceCount(&surface.previous_cells[logical_idx]) + cellInstanceCount(surface.at(col, row))
+ cellInstanceCount(core, &surface.previous_cells[logical_idx], row) + cellInstanceCount(core, surface.at(col, row), row)
else
- cellInstanceCount(surface.at(col, row));
+ cellInstanceCount(core, surface.at(col, row), row);
paint_plan.batches[batch_index].cell_count = std.math.add(
u32,
paint_plan.batches[batch_index].cell_count,
@@ -4485,7 +4930,7 @@ fn renderFrame(
destination.cell_count = std.math.add(
u32,
destination.cell_count,
- cellInstanceCount(&surface.previous_cells[logical_idx]),
+ cellInstanceCount(core, &surface.previous_cells[logical_idx], row),
) catch return error.GpuCapacity;
}
}
@@ -4779,7 +5224,7 @@ test "tagline bands face the topbar rule and Tagbottom faces the window edge" {
try std.testing.expectEqual(@as(u32, 4), taglineBandOffset(9, canvas_h, cell_h, tagline_h));
}
-fn resolveCell(g: *Gui, cell: *const pardes.Cell, is_cursor: bool, page: [3]u8) ResolvedCell {
+fn resolveCell(g: *Gui, cell: *const pardes.Cell, role: pardes.FontRole, is_cursor: bool, page: [3]u8) ResolvedCell {
var fg = fg_default;
var bg = page;
var reverse = is_cursor;
@@ -4803,10 +5248,6 @@ fn resolveCell(g: *Gui, cell: *const pardes.Cell, is_cursor: bool, page: [3]u8)
}
if (reverse) std.mem.swap([3]u8, &fg, &bg);
- const role: pardes.FontRole = if (cell.default)
- .body
- else
- cell.style.font_role;
const cp = cellCodepoint(cell);
return .{
.slot = if (cp == ' ') g.space_slot else ensureGlyph(g, cp, role),
@@ -4816,12 +5257,43 @@ fn resolveCell(g: *Gui, cell: *const pardes.Cell, is_cursor: bool, page: [3]u8)
};
}
+/// What the CORE said this cell's face is.
fn cellFontRole(cell: *const pardes.Cell) pardes.FontRole {
return if (cell.default) .body else cell.style.font_role;
}
-fn cellInstanceCount(cell: *const pardes.Cell) u32 {
- return if (cellFontRole(cell) == .tagline) 2 else 1;
+/// ...and the face it is actually DRAWN in, which differs in exactly one case
+/// and that case is the whole of what an attached window renders differently.
+///
+/// A compact tagline band is anchored at its pane's LEFT EDGE — that is what
+/// `compactTaglineLayout`'s `origin_col` is — and a pane's left edge is a pane
+/// RECT, which this wire does not carry (it carries cells, not the layout that
+/// placed them). Anchoring per cell instead is not a near-miss, it is a picket
+/// fence: `x_off = col * (body_w - tag_w)` puts every cell back on BODY pitch
+/// while the quad stays `tag_w` wide, so the chrome band shows through between
+/// every pair of cells and the text tracks visibly loose. Widening the quad
+/// does not close it either — `emitInstance` samples exactly `tagline_width`
+/// atlas texels for a tagline cell, so a wider quad stretches the glyph.
+///
+/// So a pane tag row with no core to ask goes on the BODY grid, face and all:
+/// one quad per cell at body pitch and body size, tiling exactly and tracking
+/// exactly. The visible difference from a local window is that those rows wear
+/// the body face rather than the 82% one, and that is the price of the pane
+/// rects not being on the wire. It is also the grid `gridCellAtDimensions`
+/// already hit-tests an attached tag row against, so a click still lands on the
+/// glyph it was aimed at.
+///
+/// ROW ZERO is exempt, and that exemption is why the topbar was never striped:
+/// its origin is not a pane rect but column zero, always, so its compact band
+/// is right with or without a core.
+fn drawnFontRole(core: ?*const pardes.Pardes, cell: *const pardes.Cell, row: u16) pardes.FontRole {
+ const role = cellFontRole(cell);
+ if (role != .tagline or core != null or row < pardes.TOPBAR_H) return role;
+ return .body;
+}
+
+fn cellInstanceCount(core: ?*const pardes.Pardes, cell: *const pardes.Cell, row: u16) u32 {
+ return if (drawnFontRole(core, cell, row) == .tagline) 2 else 1;
}
/// A tagline cell has two quads. The first preserves the pane-wide chrome
@@ -4830,7 +5302,7 @@ fn cellInstanceCount(cell: *const pardes.Cell) u32 {
/// the compact cell never reaches the next body's cell origin.
fn emitSurfaceCell(
g: *Gui,
- core: *const pardes.Pardes,
+ core: ?*const pardes.Pardes,
instances: [*]CellInstance,
next: *u32,
col: u16,
@@ -4845,15 +5317,16 @@ fn emitSurfaceCell(
is_cursor: bool,
page: [3]u8,
) void {
- if (cellFontRole(cell) == .tagline) {
- emitInstance(g, instances, next.*, col, row, body_layout, win_w, win_h, track, tagline_base, old_layer, false, page);
+ const role = drawnFontRole(core, cell, row);
+ if (role == .tagline) {
+ emitInstance(g, instances, next.*, col, row, body_layout, win_w, win_h, track, .tagline, tagline_base, old_layer, false, page);
next.* += 1;
const tag_layout = taglineLayoutForCell(g, core, col, row, track);
- emitInstance(g, instances, next.*, col, row, tag_layout, win_w, win_h, track, cell, old_layer, is_cursor, page);
+ emitInstance(g, instances, next.*, col, row, tag_layout, win_w, win_h, track, .tagline, cell, old_layer, is_cursor, page);
next.* += 1;
return;
}
- emitInstance(g, instances, next.*, col, row, body_layout, win_w, win_h, track, cell, old_layer, is_cursor, page);
+ emitInstance(g, instances, next.*, col, row, body_layout, win_w, win_h, track, role, cell, old_layer, is_cursor, page);
next.* += 1;
}
@@ -4867,6 +5340,11 @@ fn emitInstance(
win_w: f32,
win_h: f32,
track: ?pardes.panel_animation.Track,
+ /// The face this quad draws in, decided once per cell by `drawnFontRole`
+ /// rather than re-derived here: an attached window demotes a pane tag row
+ /// to the body face, and the quad geometry, the atlas slot and the uv span
+ /// all have to agree about that in one place.
+ role: pardes.FontRole,
cell: *const pardes.Cell,
/// Old and new data layers carry their own quad geometry. The shader
/// discards exactly one at every reveal state, so a body/tagline role
@@ -4876,7 +5354,7 @@ fn emitInstance(
/// the ground this frame: what a default background resolves to
page: [3]u8,
) void {
- const resolved = resolveCell(g, cell, is_cursor, page);
+ const resolved = resolveCell(g, cell, role, is_cursor, page);
// Cell pixel rect (top-left origin) → NDC (y up).
const px0 = layout.x_off + @as(f32, @floatFromInt(col)) * layout.w;
@@ -5001,7 +5479,7 @@ fn firstCp(s: []const u8) u32 {
/// the new cell size is a different grid over the same 2048², and a leftover
/// bitmap no slot points at any more would still be sampled by whatever new
/// slot overlaps it.
-fn refitFont(g: *Gui, core: *pardes.Pardes) void {
+fn refitFont(g: *Gui, core: ?*pardes.Pardes) void {
g.scale = c.ui_font_scale_for_height(g.font, g.px);
var cw: c_int = 10;
var chh: c_int = 20;
@@ -5022,13 +5500,13 @@ fn refitFont(g: *Gui, core: *pardes.Pardes) void {
// shells re-derive this every frame anyway, so this is only the frame the
// change happens on — but it is the frame the surface is about to be
// rendered for, and a stale screen_w here is a row of cells drawn off the
- // right edge of the window.
- var pw: c_int = 0;
- var ph: c_int = 0;
- _ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph);
- const cols: u16 = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(pw, 1))), g.cell_w)));
- const rows: u16 = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(ph, 1))), g.cell_h)));
- _ = updateCoreResize(core, cols, rows, g.cell_w, g.cell_h);
+ // right edge of the window. An attached window has no core to tell: its
+ // loop compares `windowCells` against the last geometry it sent and puts a
+ // resize on the wire from there.
+ if (core) |p| {
+ const geom = windowCells(g);
+ _ = updateCoreResize(p, geom.cols, geom.rows, g.cell_w, g.cell_h);
+ }
// ...and a fractional scroll is measured in the OLD grid: scroll_rect
// is a rect of the pane the last frame drew, and scroll_edge is a saved row
@@ -5485,7 +5963,7 @@ fn writeCapturePpm(g: *Gui, gpa: std.mem.Allocator, pixels: []const u8, width: u
var header: [64]u8 = undefined;
const hdr = std.fmt.bufPrint(&header, "P6\n{d} {d}\n255\n", .{ width, height }) catch return error.CaptureWriteFailed;
- writeFd(fd, hdr);
+ host_io.writeFd(fd, hdr);
const row_rgb = try gpa.alloc(u8, @as(usize, width) * 3);
defer gpa.free(row_rgb);
@@ -5498,7 +5976,7 @@ fn writeCapturePpm(g: *Gui, gpa: std.mem.Allocator, pixels: []const u8, width: u
row_rgb[di + 1] = src[si + 1];
row_rgb[di + 2] = src[si + if (bgr) @as(usize, 0) else 2];
}
- writeFd(fd, row_rgb);
+ host_io.writeFd(fd, row_rgb);
}
if (libc.rename(tmp_path, final_path) != 0) return error.CaptureWriteFailed;
}
@@ -5537,7 +6015,7 @@ fn addCursorBar(
fn buildOverlay(
g: *Gui,
- core: *const pardes.Pardes,
+ core: ?*const pardes.Pardes,
surface: *const pardes.Surface,
layout: CellLayout,
sw: u32,
@@ -5578,7 +6056,7 @@ fn buildOverlay(
if (surface.cursor) |cursor| {
if (cursor.x < surface.cols and cursor.y < surface.rows) {
const cell = surface.cells[@as(usize, cursor.y) * surface.cols + cursor.x];
- const role = cell.style.font_role;
+ const role = drawnFontRole(core, &cell, cursor.y);
const visual_height: f32 = if (role == .tagline)
@floatFromInt(g.tagline_height)
else
@@ -5868,15 +6346,3 @@ fn envU16(env: *std.process.Environ.Map, name: []const u8) ?u16 {
const raw = env.get(name) orelse return null;
return std.fmt.parseInt(u16, raw, 10) catch null;
}
-
-fn writeFd(fd: c_int, data: []const u8) void {
- var off: usize = 0;
- while (off < data.len) {
- const n = libc.write(fd, data[off..].ptr, data.len - off);
- if (n < 0) {
- if (libc.errno(n) == .INTR) continue;
- return;
- }
- off += @intCast(n);
- }
-}