From dba1ab69e5f38d8daf9312ab2d343b36fa078162 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 18 Aug 2026 17:53:18 -0300 Subject: term_pane + builtins: terminal pane work, builtins/config additions, snapshots --- src/pardes.zig | 138 ++++++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 121 insertions(+), 17 deletions(-) (limited to 'src/pardes.zig') diff --git a/src/pardes.zig b/src/pardes.zig index 6eb8a740..73b431e6 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -137,6 +137,7 @@ test { const pane_tail = " " ++ config.pane_builtins_str; const file_pane_tail = " " ++ config.file_pane_builtins_str; +const terminal_pane_tail = " " ++ config.terminal_pane_builtins_str; // Kept separate from the path so a click still expands to the exact filename. // It belongs to the live, read-only prefix rather than the editable command // tail: saving removes it without rewriting anything the user typed there. @@ -569,7 +570,7 @@ test "pane-tag Exec prefers New and argument builtins before shell fallback" { p.acknowledgeShell(0, "/bin/sh", false); const pane = p.panes[0].?; - try std.testing.expectEqualStrings(" New Del", Pardes.curTail(pane)); + try std.testing.expectEqualStrings(" New Del Filter", Pardes.curTail(pane)); const tag_x = p.rects[0].x + config.GUTTER; const tag_y = p.rects[0].y; @@ -634,6 +635,42 @@ test "pane-tag Exec prefers New and argument builtins before shell fallback" { try std.testing.expectEqualStrings("DefinitelyNotABuiltin\r", sent[0..sent_len]); } +test "Filter is ordered after Del and toggles only its terminal pane" { + const testing = std.testing; + const del = std.meta.stringToEnum(Builtin, "Del") orelse return error.MissingDelBuiltin; + const filter = std.meta.stringToEnum(Builtin, "Filter") orelse return error.MissingFilterBuiltin; + try testing.expectEqual(@intFromEnum(del) + 1, @intFromEnum(filter)); + + const p = try Pardes.init(testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const first = p.panes[0].?; + const palette_before = first.vt.colorForXterm(.{ .palette = 1 }).?; + + try testing.expect(first.tty_filter); + try testing.expect(p.executeBuiltinLine(0, "Filter")); + try testing.expect(!first.tty_filter); + try testing.expect(palette_before.eql(first.vt.colorForXterm(.{ .palette = 1 }).?)); + + const second_id = p.freeSlot().?; + const second = try p.newShell(second_id, ""); + try testing.expect(second.tty_filter); + try testing.expect(p.executeBuiltinLine(second_id, "Filter")); + try testing.expect(!second.tty_filter); + try testing.expect(!first.tty_filter); + + const doc_id = p.freeSlot().?; + const doc = try image_pane.create(p, doc_id, "/tmp/filter-inert.ppm", &.{}); + try testing.expect(!doc.tty_filter); + try testing.expect(p.executeBuiltinLine(doc_id, "Filter")); + try testing.expect(!doc.tty_filter); + try testing.expect(!first.tty_filter); + + try testing.expect(p.executeBuiltinLine(0, "Filter")); + try testing.expect(first.tty_filter); + try testing.expect(!second.tty_filter); +} + test "an untouched tagline ends where its layout column's widest one does" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .cols = 100, .rows = 30 }); @@ -659,7 +696,7 @@ test "an untouched tagline ends where its layout column's widest one does" { // path, which is the whole point (equal at tw - tag_right_pad would prove // nothing: that is where both sat before) try std.testing.expectEqual(below_tag.len, above_tag.len); - try std.testing.expectEqualStrings(" New Del", above_tag[above_tag.len - " New Del".len ..]); + try std.testing.expectEqualStrings(" New Del Filter", above_tag[above_tag.len - " New Del Filter".len ..]); try std.testing.expect(above_tag.len > @as(usize, p.rects[0].w) - config.GUTTER - config.tag_right_pad); try std.testing.expect(above_tag.len <= @as(usize, p.rects[0].w) - config.GUTTER); @@ -708,7 +745,16 @@ test "legacy default tag tails upgrade while custom tails remain owned" { }); p.restoreTail(terminal, terminal_old); try std.testing.expect(!terminal.tag_init); - try std.testing.expectEqualStrings(" New Del", Pardes.curTail(terminal)); + try std.testing.expectEqualStrings(" New Del Filter", Pardes.curTail(terminal)); + + // The immediately preceding release used the generic current default on + // terminals. It upgrades too, including any saved layout padding. + const terminal_previous = try std.fmt.allocPrint(p.scratch.allocator(), "{s} New Del", .{ + try p.tagPrefix(terminal), + }); + p.restoreTail(terminal, terminal_previous); + try std.testing.expect(!terminal.tag_init); + try std.testing.expectEqualStrings(" New Del Filter", Pardes.curTail(terminal)); const terminal_custom = try std.fmt.allocPrint(p.scratch.allocator(), "{s} Keep Del", .{ try p.tagPrefix(terminal), @@ -717,6 +763,31 @@ test "legacy default tag tails upgrade while custom tails remain owned" { try std.testing.expect(terminal.tag_init); try std.testing.expectEqualStrings(" Keep Del", Pardes.curTail(terminal)); + // The first dump format included tty mode in the live prefix. Its stored + // cwd still identifies where the editable bytes begin after the mode word + // disappeared from today's tag, so a custom tail must survive that move. + terminal.tag_tail_len = 0; + terminal.tag_init = false; + p.restoreDumpTail(terminal, .{ + .kind = .terminal, + .tag = "TTY /historical/cwd Keep Del", + .body = "", + .terminal = .{ .cwd = "/historical/cwd" }, + }); + try std.testing.expect(terminal.tag_init); + try std.testing.expectEqualStrings(" Keep Del", Pardes.curTail(terminal)); + + terminal.tag_tail_len = 0; + terminal.tag_init = false; + p.restoreDumpTail(terminal, .{ + .kind = .terminal, + .tag = "TTY /historical/cwd New Del", + .body = "", + .terminal = .{ .cwd = "/historical/cwd" }, + }); + try std.testing.expect(!terminal.tag_init); + try std.testing.expectEqualStrings(" New Del Filter", Pardes.curTail(terminal)); + // This happens to be the historical FILE default, but on a terminal it is // user-owned text and must not be swallowed by migration. terminal.tag_tail_len = 0; @@ -761,6 +832,7 @@ test "legacy default tag tails upgrade while custom tails remain owned" { // with only `img PATH`. Their custom tails still migrate through the // pane-specific legacy-prefix recognizer. const image_doc = try image_pane.create(p, 1, "/tmp/legacy image.ppm", &.{}); + try std.testing.expectEqualStrings(" New Del", Pardes.curTail(image_doc)); p.restoreDumpTail(image_doc, .{ .kind = .image, .tag = "img /tmp/legacy image.ppm Keep Del", @@ -1407,6 +1479,9 @@ test "restored terminals keep monotonic nonzero pane identities" { const gpa = std.testing.allocator; const source = try Pardes.init(gpa, .{ .shells = 3 }); defer source.deinit(); + // Filter is a fresh presentation default, not emulator/dump state. Even a + // pane toggled off in the source session comes back with the default on. + source.panes[1].?.tty_filter = false; try source.dumpState(); const restored = try Pardes.initFromDump(gpa, .{}, source.dump_out.?); @@ -1418,6 +1493,7 @@ test "restored terminals keep monotonic nonzero pane identities" { for (restored.panes, 0..) |slot, id| { const pane = slot orelse continue; if (!pane.isTerminal()) continue; + try std.testing.expect(pane.tty_filter); try std.testing.expect(pane.serial != 0); for (serials[0..n]) |seen| try std.testing.expect(pane.serial != seen); ids[n] = id; @@ -1431,6 +1507,7 @@ test "restored terminals keep monotonic nonzero pane identities" { const before = restored.next_serial; const free = restored.freeSlot() orelse return error.NoFreePane; const fresh = try restored.newShell(free, ""); + try std.testing.expect(fresh.tty_filter); try std.testing.expectEqual(before + 1, fresh.serial); try std.testing.expectEqual(fresh.serial, restored.next_serial); } @@ -1681,8 +1758,9 @@ const EFFECT_CAP = 4096; // one update may queue 256 KiB of ordered 64-byte writ /// Everything a theme repaints. `bg`/`fg` null = leave the host terminal's own /// default cell showing (the native-dark shape); `palette` null = let a child's -/// ANSI indices reach the host untranslated. The gutter's move box lives here -/// too: it used to be a pair of module constants, but a theme that wants to be +/// ANSI indices reach the host untranslated until that terminal enables its +/// theme-keyed Filter. The gutter's move box lives here too: it used to be a +/// pair of module constants, but a theme that wants to be /// restrained has to be able to turn the accent DOWN, and the box is the one /// piece of loud chrome on screen. The overlay/drag greys that were also /// hardcoded turned out to be the dark theme's own scroll_track/lineno/tag_fg @@ -3203,6 +3281,10 @@ pub const Pane = struct { tty_stream: [TTY_REPLAY_CAP]u8 = undefined, tty_stream_head: usize = 0, tty_stream_len: usize = 0, + /// Terminal-only, pane-local presentation mode. Ghostty remains the owner + /// of the unmodified VT palette and dynamic OSC colours; the renderer + /// projects them through the active Pardes theme when this is set. + tty_filter: bool = false, /// query replies ghostty computed (DSR, DA, kitty); the stream handler has /// no path to the effect queue, so they land here and sync() drains them /// into write effects. Bounded: replies are tiny escape sequences. @@ -4703,6 +4785,10 @@ pub const Pardes = struct { /// authoritative record. Generated setting builtins mutate it directly; /// rendering and the Config report read those same fields. settings: runtime_cfg.State = .{ .font = .{ .tagline_percent = config.gui_tagline_font_percent } }, + /// One theme-derived Ghostty palette shared by all filtered terminals. + /// Its value key makes a same-name ThemeFile reload invalidate it without + /// coupling terminal rendering to the theme-selection call sites. + tty_filter_palette: term_pane.FilterPalette = .{}, /// Delivery bookkeeping, not configuration: prevents a synchronous host /// pump from taking one still-pending font request more than once before it /// acknowledges success or rejection. @@ -5715,7 +5801,15 @@ pub const Pardes = struct { /// (a buffer with nothing to Save gets the plain tail — the table decides) fn curTail(pane: *Pane) []const u8 { if (pane.tag_init) return pane.tagSlice(); + return defaultTail(pane); + } + + /// The untouched command tail for this pane class. `curTail` and tagGap + /// must ask the same question: otherwise a terminal renders Filter but + /// still votes for the shorter generic tail when a column is aligned. + fn defaultTail(pane: *const Pane) []const u8 { if (pane.file) |f| if (output_pane.fileTraits(f.output).saves) return file_pane_tail; + if (pane.isTerminal()) return terminal_pane_tail; return pane_tail; } @@ -5793,10 +5887,7 @@ pub const Pardes = struct { // prefix ++ the spaces in front of the words ++ the words: the // frozen gap for a touched tail, the default's own single leading // space for an untouched one (which is why there is no +1 here) - const words = if (q.file) |f| - (if (output_pane.fileTraits(f.output).saves) file_pane_tail else pane_tail) - else - pane_tail; + const words = defaultTail(q); const laid = if (q.tag_init) q.tagSlice() else words; const lead = laid.len - std.mem.trimStart(u8, laid, " ").len; const q_end = file_pane.displayWidth(p.tagPrefix(q) catch continue) + lead + file_pane.displayWidth(std.mem.trimStart(u8, words, " ")); @@ -12341,10 +12432,18 @@ pub const Pardes = struct { pane.tag_init = true; return; } - const tail_class: TailClass = if (src.kind == .file) - tailClass(pane) - else - .generic; + const tail_class = tailClass(pane); + // Early dumps put tty mode in the live prefix itself. The cwd stored + // beside the replay stream is the stable half of that historical + // prefix; use it to recover both custom tails and defaults even though + // today's tag has only the mode box plus cwd. Check this before the + // current prefix because a not-yet-reported cwd is legitimately empty + // and therefore a prefix of every saved tag. + if (src.kind == .terminal and std.mem.startsWith(u8, src.tag, "TTY ")) { + const legacy = std.fmt.allocPrint(p.scratch.allocator(), "TTY {s}", .{src.terminal.?.cwd}) catch return; + if (std.mem.startsWith(u8, src.tag, legacy)) + return p.restoreTailAt(pane, src.tag, legacy, .terminal); + } const current = p.tagPrefix(pane) catch return; if (std.mem.startsWith(u8, src.tag, current)) return p.restoreTailAt(pane, src.tag, savedPrefix(src.tag, current, tail_class), tail_class); @@ -12365,10 +12464,11 @@ pub const Pardes = struct { p.restoreTailAt(pane, saved_tag, savedPrefix(saved_tag, pfx, class), class); } - const TailClass = enum { generic, savable_file }; + const TailClass = enum { generic, savable_file, terminal }; fn tailClass(pane: *const Pane) TailClass { if (pane.file) |f| if (output_pane.fileTraits(f.output).saves) return .savable_file; + if (pane.isTerminal()) return .terminal; return .generic; } @@ -12398,9 +12498,13 @@ pub const Pardes = struct { // tail SAYS and not where it sat: leading spaces are layout, never // content, and a default that came back padded is still a default. const said = std.mem.trimStart(u8, rest, " "); - const defaults = switch (class) { - .generic => [2][]const u8{ pane_tail, legacy_pane_tail }, - .savable_file => [2][]const u8{ file_pane_tail, legacy_file_pane_tail }, + const defaults: []const []const u8 = switch (class) { + .generic => &.{ pane_tail, legacy_pane_tail }, + .savable_file => &.{ file_pane_tail, legacy_file_pane_tail }, + // Terminal dumps written before Filter used the generic current + // tail, and the earliest version used just Del. Both upgrade; + // other pane classes still treat this new default as user text. + .terminal => &.{ terminal_pane_tail, pane_tail, legacy_pane_tail }, }; for (defaults) |default| if (std.mem.eql(u8, said, std.mem.trimStart(u8, default, " "))) return; -- cgit v1.3