summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/builtins.zig11
-rw-r--r--src/config.zig12
-rw-r--r--src/pardes.zig138
-rw-r--r--src/term_pane.zig416
-rw-r--r--src/tutor.txt18
5 files changed, 561 insertions, 34 deletions
diff --git a/src/builtins.zig b/src/builtins.zig
index 3f1c5f85..ca3cf931 100644
--- a/src/builtins.zig
+++ b/src/builtins.zig
@@ -569,6 +569,17 @@ pub const Del = struct {
}
};
+/// Project this terminal's displayed cell foregrounds and backgrounds through
+/// the current Pardes theme. The emulator keeps its original colour state;
+/// only this pane's rendered cells change, so OSC queries and later resets
+/// remain truthful.
+pub const Filter = struct {
+ pub fn run(c: Ctx) void {
+ if (!c.pane.isTerminal()) return;
+ c.pane.tty_filter = !c.pane.tty_filter;
+ }
+};
+
pub const Delcol = struct {
pub fn run(c: Ctx) void {
const f = c.p.layoutFindTerm(c.id) orelse return;
diff --git a/src/config.zig b/src/config.zig
index 965570f9..83cd2a99 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -108,6 +108,10 @@ pub const leader_path = paths: {
.Lspinfo = "li",
.Lspwhy = "lw",
.Del = "d",
+ // A terminal-pane tag owns this presentation switch. It deliberately
+ // has no global leader path: executing the word beside that terminal
+ // makes the pane-local scope visible at the point of use.
+ .Filter = null,
.Kill = "k",
// THE CLIPBOARD MENU, on helix's own five letters and nowhere else.
// These are the only paths in the table that keep their helix spelling
@@ -374,11 +378,13 @@ pub const tty_blank: enum { prompt, prompt_and_input } = .prompt;
pub const topbar_str = "New Newcol Find Grep Help Tutor Dump NextColor Debug Kill";
/// the default editable tail of a pane's tag, per kind (an output buffer has
-/// no file to Save, so it gets the plain one). Save stays first on real files:
-/// `:` parks at the tail boundary, so the established `:w<Tab>` spelling still
-/// walks to Save while New remains visible on every pane.
+/// no file to Save, so it gets the plain one). Terminals alone expose Filter,
+/// the pane-local theme-keyed colour projection. Save stays first on real
+/// files: `:` parks at the tail boundary, so the established `:w<Tab>`
+/// spelling still walks to Save while New remains visible on every pane.
pub const pane_builtins_str = "New Del";
pub const file_pane_builtins_str = "Save New Del";
+pub const terminal_pane_builtins_str = "New Del Filter";
/// Columns kept clear to the RIGHT of a tagline's builtins. The path stays at
/// the left edge and the builtins are pushed over to end this far short of the
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;
diff --git a/src/term_pane.zig b/src/term_pane.zig
index adba1cd6..cc0cc24e 100644
--- a/src/term_pane.zig
+++ b/src/term_pane.zig
@@ -22,6 +22,75 @@ const dump = @import("dump.zig");
pub const history_max = 64; // snapshots copy the whole edit buffer; keep this tighter than files
+const GColor = ghostty_vt.color;
+
+/// The inputs which completely determine a filtered terminal palette. Theme
+/// names and indices are intentionally absent: ThemeFile may replace a theme
+/// in place under the same name, while equal colour values need no rebuild.
+const FilterPaletteKey = struct {
+ bg: GColor.RGB,
+ fg: GColor.RGB,
+ base: [16]GColor.RGB,
+};
+
+/// Cached 256-colour projection of the current Pardes theme. Ghostty owns the
+/// interpolation: its CIELAB cube and greyscale ramp give every xterm key a
+/// theme-derived RGB value while retaining the conventional dark-to-light
+/// index orientation on light themes (`harmonious = false`).
+pub const FilterPalette = struct {
+ key: ?FilterPaletteKey = null,
+ colors: GColor.Palette = GColor.default,
+
+ fn get(self: *FilterPalette, theme: *const pardes.Theme) *const GColor.Palette {
+ const bg = asGhostRgb(theme.bg orelse theme.tag_bg);
+ const fg = asGhostRgb(theme.fg orelse theme.tag_fg);
+ var base: [16]GColor.RGB = undefined;
+ if (theme.palette) |palette| {
+ for (&base, palette) |*dst, src| dst.* = asGhostRgb(src);
+ } else {
+ // A theme without an ANSI table still supplies every key. The
+ // first eight keep the usual semantic families; their bright
+ // partners use the same accents plus the theme's lighter chrome.
+ const synthesized = [16][3]u8{
+ theme.bg orelse theme.tag_bg,
+ theme.kw,
+ theme.str,
+ theme.num,
+ theme.box,
+ theme.sel_bg,
+ theme.comment,
+ theme.fg orelse theme.tag_fg,
+ theme.lineno,
+ theme.kw,
+ theme.str,
+ theme.num,
+ theme.scroll_thumb,
+ theme.sel_fg,
+ theme.tag_fg,
+ theme.fg orelse theme.tag_fg,
+ };
+ for (&base, synthesized) |*dst, src| dst.* = asGhostRgb(src);
+ }
+
+ const key: FilterPaletteKey = .{ .bg = bg, .fg = fg, .base = base };
+ if (self.key) |old| if (std.meta.eql(old, key)) return &self.colors;
+
+ var seed = GColor.default;
+ for (base, 0..) |rgb, i| seed[i] = rgb;
+ self.colors = GColor.generate256Color(seed, .initEmpty(), bg, fg, false);
+ self.key = key;
+ return &self.colors;
+ }
+};
+
+fn asGhostRgb(rgb: [3]u8) GColor.RGB {
+ return .{ .r = rgb[0], .g = rgb[1], .b = rgb[2] };
+}
+
+fn asPardesColor(rgb: GColor.RGB) pardes.Color {
+ return .{ .rgb = .{ rgb.r, rgb.g, rgb.b } };
+}
+
/// The owned text standing in for `rows` live terminal rows, beginning at
/// absolute surface row `row`. The emulator grid remains untouched underneath.
pub const EditBuffer = struct {
@@ -152,6 +221,9 @@ pub fn create(gpa: std.mem.Allocator, cols: u16, rows: u16) !*Pane {
.gpa = gpa,
.cols = cols,
.rows = rows,
+ // Real terminals start theme-keyed. Document panes use the separate
+ // 1x1 stub constructor and retain Pane's inert false default.
+ .tty_filter = true,
};
pane.stream = pane.vt.vtStream();
// Answer terminal queries (DSR/DA/kitty) back into the pty, else
@@ -891,6 +963,11 @@ pub fn recolorAnsi(p: *Pardes, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, bo
const s = &p.surface;
const goff: i32 = @intCast(pane.vt.screens.active.pages.scrollbar().offset);
const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+ var filtered_storage: FilteredColors = undefined;
+ const filtered: ?*FilteredColors = if (pane.tty_filter) blk: {
+ filtered_storage = FilteredColors.init(p, pane);
+ break :blk &filtered_storage;
+ } else null;
var vr: u16 = 0;
while (vr < body_h and vr < pane.rows) : (vr += 1) {
const grid = pane.gridRow(off + @as(i32, vr)) - goff;
@@ -901,19 +978,29 @@ pub fn recolorAnsi(p: *Pardes, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, bo
var c: u16 = 0;
while (c < tw) : (c += 1) {
const ci = pane.vt.screens.active.pages.getCell(.{ .viewport = .{ .x = @intCast(c), .y = @intCast(grid) } }) orelse continue;
- if (ci.cell.wide == .spacer_tail) continue;
+ // bodyText already gave a wide glyph's empty surface tail the
+ // head's body style. Ordinary ANSI projection historically leaves
+ // that alone; Filter must replace it too or one half of the cell
+ // can retain a host/default colour (Ghostty gives the tail the
+ // same style id as its wide head).
+ if (ci.cell.wide == .spacer_tail and filtered == null) continue;
const cell = s.at(tx + c, body_y + vr);
- if (cell.default) continue;
- cell.style = cellStyle(p, ci);
+ // In ordinary ANSI mode retain the old sparse projection: bodyText
+ // already painted every visible glyph. A filter paints the whole
+ // VT viewport so even empty/default and background-only cells are
+ // RGB-keyed by the theme rather than leaking the host defaults.
+ if (cell.default and filtered == null) continue;
+ cell.default = false;
+ cell.style = cellStyle(p, ci, filtered);
}
}
}
-pub fn cellStyle(p: *Pardes, ci: ghostty_vt.PageList.Cell) pardes.CellStyle {
+fn cellStyle(p: *Pardes, ci: ghostty_vt.PageList.Cell, filtered: ?*FilteredColors) pardes.CellStyle {
const style = ci.style();
var cs: pardes.CellStyle = .{
- .fg = ghostColor(p, style.fg_color, false),
- .bg = ghostColor(p, style.bg_color, true),
+ .fg = if (filtered) |colors| colors.fg(style) else ghostColor(p, style.fg_color, false),
+ .bg = if (filtered) |colors| colors.bg(style, ci.cell) else ghostColor(p, style.bg_color, true),
.bold = style.flags.bold,
.dim = style.flags.faint,
.italic = style.flags.italic,
@@ -930,17 +1017,330 @@ pub fn cellStyle(p: *Pardes, ci: ghostty_vt.PageList.Cell) pardes.CellStyle {
.dashed => .dashed,
},
};
- switch (ci.cell.content_tag) {
+ // Style.bg above already resolves Ghostty's color-only cell variants for
+ // the filtered path. Preserve the established direct translation outside
+ // it, where indexed colours are intentionally allowed to reach the host.
+ if (filtered == null) switch (ci.cell.content_tag) {
.bg_color_palette => cs.bg = palColor(p, ci.cell.content.color_palette.data),
.bg_color_rgb => {
const rgb = ci.cell.content.color_rgb;
cs.bg = .{ .rgb = .{ rgb.r, rgb.g, rgb.b } };
},
else => {},
- }
+ };
return cs;
}
+/// Per-render resolver. The source palette is materialized through Ghostty's
+/// public xterm API, so OSC 4 changes participate without reaching into the
+/// emulator's private state. Truecolour and visually overridden entries are
+/// reduced to the nearest canonical Ghostty palette key; the key then indexes
+/// the theme palette. Repeated RGBs pay that search only once per frame.
+const FilteredColors = struct {
+ source: GColor.Palette,
+ target: *const GColor.Palette,
+ theme_bg: GColor.RGB,
+ theme_fg: GColor.RGB,
+ dynamic_bg: ?GColor.RGB,
+ dynamic_fg: ?GColor.RGB,
+ // Direct-mapped rather than append-only: a frame which encounters more
+ // than the cache's capacity must not strand every later (and repeated)
+ // colour on the 256-entry nearest-key scan. The RGB hash spreads the
+ // common 6x6x6 cube values instead of keying on their low bits.
+ cache_rgb: [256]GColor.RGB = undefined,
+ cache_key: [256]u8 = undefined,
+ cache_valid: [256]bool = @splat(false),
+
+ fn init(p: *Pardes, pane: *const Pane) FilteredColors {
+ var source = GColor.default;
+ for (&source, 0..) |*rgb, i|
+ rgb.* = pane.vt.colorForXterm(.{ .palette = @intCast(i) }) orelse rgb.*;
+ const theme = p.theme();
+ var theme_bg = asGhostRgb(theme.bg orelse theme.tag_bg);
+ var theme_fg = asGhostRgb(theme.fg orelse theme.tag_fg);
+ var dynamic_bg = pane.vt.colorForXterm(.{ .dynamic = .background });
+ var dynamic_fg = pane.vt.colorForXterm(.{ .dynamic = .foreground });
+ // DECSCNM swaps only the terminal's default color roles; explicit SGR
+ // colors stay explicit. Reuse Ghostty's parsed mode instead of trying
+ // to infer the escape from cells, just as its renderer does.
+ if (pane.vt.modes.get(.reverse_colors)) {
+ std.mem.swap(GColor.RGB, &theme_bg, &theme_fg);
+ std.mem.swap(?GColor.RGB, &dynamic_bg, &dynamic_fg);
+ }
+ return .{
+ .source = source,
+ .target = p.tty_filter_palette.get(theme),
+ .theme_bg = theme_bg,
+ .theme_fg = theme_fg,
+ .dynamic_bg = dynamic_bg,
+ .dynamic_fg = dynamic_fg,
+ };
+ }
+
+ fn fg(self: *FilteredColors, style: ghostty_vt.Style) pardes.Color {
+ const resolved = style.fg(.{
+ .default = self.dynamic_fg orelse self.theme_fg,
+ .palette = &self.source,
+ .bold = null,
+ });
+ return switch (style.fg_color) {
+ .none => if (self.dynamic_fg) |rgb| self.keyed(rgb) else asPardesColor(self.theme_fg),
+ .palette => |idx| self.palette(idx, resolved),
+ .rgb => self.keyed(resolved),
+ };
+ }
+
+ fn bg(self: *FilteredColors, style: ghostty_vt.Style, cell: *const ghostty_vt.Cell) pardes.Color {
+ const resolved = style.bg(cell, &self.source);
+ return switch (cell.content_tag) {
+ .bg_color_palette => self.palette(cell.content.color_palette.data, resolved.?),
+ .bg_color_rgb => self.keyed(resolved.?),
+ else => switch (style.bg_color) {
+ .none => if (self.dynamic_bg) |rgb| self.keyed(rgb) else asPardesColor(self.theme_bg),
+ .palette => |idx| self.palette(idx, resolved.?),
+ .rgb => self.keyed(resolved.?),
+ },
+ };
+ }
+
+ /// Preserve an ordinary indexed colour's semantic key. A value changed by
+ /// OSC 4 instead carries arbitrary RGB intent, so key that RGB the same way
+ /// as truecolour. Setting an entry to its exact original value is visually
+ /// indistinguishable and correctly takes this fast path.
+ fn palette(self: *FilteredColors, idx: u8, current: GColor.RGB) pardes.Color {
+ if (current.eql(GColor.default[idx])) return asPardesColor(self.target[idx]);
+ return self.keyed(current);
+ }
+
+ fn keyed(self: *FilteredColors, rgb: GColor.RGB) pardes.Color {
+ const key = self.nearestKey(rgb);
+ return asPardesColor(self.target[key]);
+ }
+
+ fn nearestKey(self: *FilteredColors, rgb: GColor.RGB) u8 {
+ const rgb24 = (@as(u32, rgb.r) << 16) | (@as(u32, rgb.g) << 8) | rgb.b;
+ const slot: u8 = @truncate((rgb24 *% 0x9e3779b1) >> 24);
+ if (self.cache_valid[slot] and self.cache_rgb[slot].eql(rgb))
+ return self.cache_key[slot];
+
+ var best: u8 = 0;
+ var best_distance: u32 = std.math.maxInt(u32);
+ for (GColor.default, 0..) |candidate, i| {
+ const distance = colorDistance(rgb, candidate);
+ // Strict comparison makes duplicate-colour ties stable at the
+ // lowest canonical xterm key.
+ if (distance < best_distance) {
+ best_distance = distance;
+ best = @intCast(i);
+ }
+ }
+ self.cache_rgb[slot] = rgb;
+ self.cache_key[slot] = best;
+ self.cache_valid[slot] = true;
+ return best;
+ }
+};
+
+fn colorDistance(a: GColor.RGB, b: GColor.RGB) u32 {
+ const dr = @as(i32, a.r) - @as(i32, b.r);
+ const dg = @as(i32, a.g) - @as(i32, b.g);
+ const db = @as(i32, a.b) - @as(i32, b.b);
+ return @intCast(dr * dr + dg * dg + db * db);
+}
+
+test "terminal Filter keys indexed truecolor OSC and background-only cells through the theme" {
+ const testing = std.testing;
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 18, .rows = 6 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ try testing.expect(pane.tty_filter);
+ pane.tty_filter = false;
+
+ // 1: ANSI base key; 196: extended key; true red exactly matches canonical
+ // key 196. The two backgrounds repeat key 25 as indexed and truecolour.
+ // Erase-to-EOL under that background makes Ghostty color-only cells.
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[31mA" ++
+ "\x1b[38;5;196mB" ++
+ "\x1b[38;2;255;0;0mC" ++
+ "\x1b[0;48;5;25mD" ++
+ "\x1b[0;48;2;0;95;175mE" ++
+ "\x1b[0;1;2;3;4;5;7;8;9mF" ++
+ "\x1b[0;48;5;25m\x1b[K" ++
+ "\r\n\x1b[0;38;5;2m界" } });
+
+ var frame = std.heap.ArenaAllocator.init(testing.allocator);
+ defer frame.deinit();
+ const r = p.rects[0];
+ const tx = r.x + config.GUTTER;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+
+ const raw = try p.render(frame.allocator());
+ try testing.expectEqual(pardes.Color{ .index = 1 }, raw.at(tx, body_y).style.fg);
+ try testing.expectEqual(pardes.Color{ .index = 196 }, raw.at(tx + 1, body_y).style.fg);
+ try testing.expectEqual(pardes.Color{ .rgb = .{ 255, 0, 0 } }, raw.at(tx + 2, body_y).style.fg);
+
+ pane.tty_filter = true;
+ _ = frame.reset(.retain_capacity);
+ const filtered = try p.render(frame.allocator());
+ var expected_cache: FilterPalette = .{};
+ const expected = expected_cache.get(p.theme());
+ try testing.expectEqual(asPardesColor(expected[1]), filtered.at(tx, body_y).style.fg);
+ try testing.expectEqual(asPardesColor(expected[196]), filtered.at(tx + 1, body_y).style.fg);
+ try testing.expectEqual(filtered.at(tx + 1, body_y).style.fg, filtered.at(tx + 2, body_y).style.fg);
+ try testing.expectEqual(asPardesColor(expected[25]), filtered.at(tx + 3, body_y).style.bg);
+ try testing.expectEqual(filtered.at(tx + 3, body_y).style.bg, filtered.at(tx + 4, body_y).style.bg);
+
+ const attrs = filtered.at(tx + 5, body_y).style;
+ try testing.expect(attrs.bold);
+ try testing.expect(attrs.dim);
+ try testing.expect(attrs.italic);
+ try testing.expect(attrs.blink);
+ try testing.expect(attrs.reverse);
+ try testing.expect(attrs.invisible);
+ try testing.expect(attrs.strikethrough);
+ try testing.expectEqual(.single, attrs.ul);
+
+ const erased = pane.vt.screens.active.pages.getCell(.{ .viewport = .{ .x = 6, .y = 0 } }).?;
+ try testing.expectEqual(.bg_color_palette, erased.cell.content_tag);
+ try testing.expectEqual(asPardesColor(expected[25]), filtered.at(tx + 6, body_y).style.bg);
+ try testing.expectEqual(asPardesColor(expected[2]), filtered.at(tx, body_y + 1).style.fg);
+ try testing.expectEqual(filtered.at(tx, body_y + 1).style, filtered.at(tx + 1, body_y + 1).style);
+ // A filtered terminal never delegates either colour to a backend palette,
+ // including cells which were empty/default before the pass.
+ for (0..r.w - config.GUTTER) |col| {
+ const cell = filtered.at(tx + @as(u16, @intCast(col)), body_y);
+ try testing.expect(!cell.default);
+ switch (cell.style.fg) {
+ .rgb => {},
+ else => return error.FilteredForegroundWasNotRgb,
+ }
+ switch (cell.style.bg) {
+ .rgb => {},
+ else => return error.FilteredBackgroundWasNotRgb,
+ }
+ }
+
+ // Colors remains the global master gate. The pane remembers Filter while
+ // ANSI projection is dormant, and resumes it without replaying VT bytes.
+ p.settings.colors = false;
+ _ = frame.reset(.retain_capacity);
+ const plain = try p.render(frame.allocator());
+ try testing.expect(pane.tty_filter);
+ try testing.expectEqual(asPardesColor(asGhostRgb(p.theme().fg.?)), plain.at(tx, body_y).style.fg);
+ p.settings.colors = true;
+
+ // OSC 4 changes the value behind an existing indexed cell. Filter treats
+ // that arbitrary value like truecolour, while toggling remains purely a
+ // presentation operation and cannot alter Ghostty's query answer.
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]4;1;#ff0000\x1b\\" } });
+ const osc_red = pane.vt.colorForXterm(.{ .palette = 1 }).?;
+ try testing.expect(osc_red.eql(.{ .r = 255, .g = 0, .b = 0 }));
+ pane.tty_filter = false;
+ pane.tty_filter = true;
+ try testing.expect(osc_red.eql(pane.vt.colorForXterm(.{ .palette = 1 }).?));
+ _ = frame.reset(.retain_capacity);
+ const osc_palette = try p.render(frame.allocator());
+ try testing.expectEqual(asPardesColor(expected[196]), osc_palette.at(tx, body_y).style.fg);
+
+ // Dynamic default foreground/background colours key every default cell,
+ // including the otherwise blank end of the row.
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]10;#ff0000\x1b\\" ++
+ "\x1b]11;#5f5f5f\x1b\\" } });
+ const dyn_fg = pane.vt.colorForXterm(.{ .dynamic = .foreground }).?;
+ const dyn_bg = pane.vt.colorForXterm(.{ .dynamic = .background }).?;
+ try testing.expect(dyn_fg.eql(.{ .r = 255, .g = 0, .b = 0 }));
+ try testing.expect(dyn_bg.eql(.{ .r = 95, .g = 95, .b = 95 }));
+ _ = frame.reset(.retain_capacity);
+ const dynamic = try p.render(frame.allocator());
+ const blank = dynamic.at(tx + r.w - config.GUTTER - 1, body_y + 2).style;
+ try testing.expectEqual(asPardesColor(expected[196]), blank.fg);
+ try testing.expectEqual(asPardesColor(expected[59]), blank.bg);
+
+ // Ghostty owns DEC reverse-screen parsing. Filter follows that mode for
+ // the dynamic/default roles while leaving an explicit ANSI foreground on
+ // an existing cell bound to the same semantic palette key.
+ const explicit_before_reverse = dynamic.at(tx, body_y).style.fg;
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?5h" } });
+ _ = frame.reset(.retain_capacity);
+ const reversed = try p.render(frame.allocator());
+ const reversed_blank = reversed.at(tx + r.w - config.GUTTER - 1, body_y + 2).style;
+ try testing.expectEqual(asPardesColor(expected[59]), reversed_blank.fg);
+ try testing.expectEqual(asPardesColor(expected[196]), reversed_blank.bg);
+ try testing.expectEqual(explicit_before_reverse, reversed.at(tx, body_y).style.fg);
+
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?5l" } });
+ _ = frame.reset(.retain_capacity);
+ const unreversed = try p.render(frame.allocator());
+ const unreversed_blank = unreversed.at(tx + r.w - config.GUTTER - 1, body_y + 2).style;
+ try testing.expectEqual(asPardesColor(expected[196]), unreversed_blank.fg);
+ try testing.expectEqual(asPardesColor(expected[59]), unreversed_blank.bg);
+
+ // The cache is keyed by values, not a theme name. Replacing a custom
+ // theme in place immediately recolours already-rendered indexed cells.
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b]104;1\x1b\\" } });
+ var custom = p.theme().*;
+ custom.name = try p.gpa.dupe(u8, "same-name");
+ custom.palette = null;
+ custom.kw = .{ 1, 2, 3 };
+ p.custom_theme = custom;
+ p.custom_theme_active = true;
+ _ = frame.reset(.retain_capacity);
+ const custom_first = try p.render(frame.allocator());
+ try testing.expectEqual(pardes.Color{ .rgb = .{ 1, 2, 3 } }, custom_first.at(tx, body_y).style.fg);
+ if (p.custom_theme) |*theme| theme.kw = .{ 4, 5, 6 };
+ _ = frame.reset(.retain_capacity);
+ const custom_second = try p.render(frame.allocator());
+ try testing.expectEqual(pardes.Color{ .rgb = .{ 4, 5, 6 } }, custom_second.at(tx, body_y).style.fg);
+}
+
+test "terminal Filter keeps extended keys dark-to-light on a light theme" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true });
+ defer p.deinit();
+ // Curated order is a public theme contract: helix, dark, acme.
+ p.settings.theme = 2;
+ try std.testing.expectEqualStrings("acme", p.theme().name);
+ var cache: FilterPalette = .{};
+ const palette = cache.get(p.theme());
+ try std.testing.expect(palette[16].eql(asGhostRgb(p.theme().fg.?)));
+ try std.testing.expect(palette[231].eql(asGhostRgb(p.theme().bg.?)));
+}
+
+test "terminal Filter preserves exact palette-null light theme default roles" {
+ const testing = std.testing;
+ const p = try Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 12, .rows = 5 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+
+ var light = p.theme().*;
+ light.name = try p.gpa.dupe(u8, "filter-light-defaults");
+ light.bg = .{ 0xf8, 0xf8, 0xf8 };
+ light.fg = .{ 0x38, 0x38, 0x38 };
+ light.palette = null;
+ p.custom_theme = light;
+ p.custom_theme_active = true;
+
+ const pane = p.panes[0].?;
+ try testing.expectEqual(@as(?GColor.RGB, null), pane.vt.colorForXterm(.{ .dynamic = .foreground }));
+ try testing.expectEqual(@as(?GColor.RGB, null), pane.vt.colorForXterm(.{ .dynamic = .background }));
+ pane.tty_filter = true;
+
+ var frame = std.heap.ArenaAllocator.init(testing.allocator);
+ defer frame.deinit();
+ const r = p.rects[0];
+ const tx = r.x + config.GUTTER;
+ const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H;
+ const ordinary = try p.render(frame.allocator());
+ try testing.expectEqual(pardes.Color{ .rgb = light.fg.? }, ordinary.at(tx, body_y).style.fg);
+ try testing.expectEqual(pardes.Color{ .rgb = light.bg.? }, ordinary.at(tx, body_y).style.bg);
+
+ p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?5h" } });
+ _ = frame.reset(.retain_capacity);
+ const reversed = try p.render(frame.allocator());
+ try testing.expectEqual(pardes.Color{ .rgb = light.bg.? }, reversed.at(tx, body_y).style.fg);
+ try testing.expectEqual(pardes.Color{ .rgb = light.fg.? }, reversed.at(tx, body_y).style.bg);
+}
+
pub fn palColor(p: *Pardes, idx: u8) pardes.Color {
if (p.theme().palette) |pal| if (idx < 16) return .{ .rgb = pal[idx] };
return .{ .index = idx };
diff --git a/src/tutor.txt b/src/tutor.txt
index 5a8335d3..193ef29c 100644
--- a/src/tutor.txt
+++ b/src/tutor.txt
@@ -139,9 +139,13 @@
"Save", so a bare `:` then Tab saves. `:w<Tab>` is w
onto "Save", Tab to execute it, and you land back in the body where you
left off (the chord keys are the same here as anywhere else; `i` if you
- would rather type a command than walk to one). Everything else — a
- terminal, an image, a PDF, an output buffer like "+Search" — shows the
- plain "New Del", because there is no file behind it to Save. An image
+ would rather type a command than walk to one). A terminal shows
+ "New Del Filter": Filter starts on, is local to that terminal, and
+ projects every rendered foreground and background ANSI, 256-colour and
+ truecolour value through keys made from the current Pardes theme; run it
+ to restore the program's original colours.
+ Images, PDFs, and output buffers like "+Search" show the plain "New Del",
+ because there is no file behind them to Save. An image
tag leads with "img" and a PDF's with the word "pdf", its page out of
the count, and its three commands each preceded by its current setting
(part 4.1) — all of it live chrome, like the path.
@@ -187,7 +191,8 @@
wear the one you stopped on. The bar holds only what you reach for
often — the rest of the builtins are words you execute and keys you
press. Nearly every one has a KEY PATH under SPC (part 3.13): "Colors"
- (the syntax/ansi recolor) is SPC t c, the native GUI scene effects are
+ (the global syntax/ANSI master switch) is SPC t c; a terminal's Filter
+ setting stays remembered while Colors is off. The native GUI scene effects are
SPC t r/R/g, panel transitions are SPC a s/z/d/a/v, and SPC ? lists the lot.
Right-click a directory in any body to open a terminal there.
@@ -1153,8 +1158,8 @@ abc
select-then-act: middle-drag, or v/x then Tab
chords: 1-2 cut 1-3 paste (both in one hold = snarf)
2-1 = middle-exec with the left selection as argument
- file tag = path + "Save New Del"; everything else,
- output buffers included, shows "New Del"
+ file tag = path + "Save New Del"; terminal = cwd +
+ "New Del Filter"; other panes show "New Del"
top bar = New Newcol Find Grep Help Tutor Dump ... Debug Kill
(keyboard: k off the topmost tagline)
WEB TOUCH: one-finger tap = LOOK; drag = natural scroll
@@ -1164,6 +1169,7 @@ abc
TTY: a terminal IS a pane; Ctrl-b (or Shift-Esc) toggles the shell,
landing its cursor where you navigated (prompt start if
the input is empty, mid-text otherwise)
+ Filter in its tag toggles a pane-local, theme-keyed palette
KEYS (helix): h j k l w b e 0 $ ^ gg ge f F t T v x X d c y p
i a I A o O u undo U redo Enter=look Tab=execute
w b e f F t T SELECT what they cross, so `i` after one