summaryrefslogtreecommitdiff
path: root/src/gui
diff options
context:
space:
mode:
Diffstat (limited to 'src/gui')
-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);
- }
-}