summaryrefslogtreecommitdiff
path: root/src/pardes.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/pardes.zig')
-rw-r--r--src/pardes.zig138
1 files changed, 121 insertions, 17 deletions
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;