diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/builtins.zig | 11 | ||||
| -rw-r--r-- | src/config.zig | 12 | ||||
| -rw-r--r-- | src/pardes.zig | 138 | ||||
| -rw-r--r-- | src/term_pane.zig | 416 | ||||
| -rw-r--r-- | src/tutor.txt | 18 |
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 |
