diff options
Diffstat (limited to 'src/gui')
| -rw-r--r-- | src/gui/gui.zig | 976 |
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); - } -} |
