From 682e237df7e8b22f820d14a4adee58e6e2f84268 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Fri, 11 Sep 2026 12:38:56 -0300 Subject: trunk: resume before the Reload experiment Empty marker on the last pre-Reload change. Keep the Reload experiment on reload (3801914), its first change on reload-start (200a1fc), and the unfinished performance investigation on reload-perf-wip. --- src/builtins.zig | 26 +- src/config.zig | 92 ++- src/dump.zig | 9 + src/fs.zig | 51 +- src/gui/gui.zig | 435 +++++++++++- src/gui/pet.zig | 170 +++++ src/layout.zig | 327 ++++++++- src/macos.zig | 3 +- src/macos/Sources/AppDelegate.swift | 5 +- src/macos/Sources/PardesView.swift | 5 +- src/macos/pardes.h | 3 +- src/panes.zig | 158 ++++- src/pardes.zig | 1335 ++++++++++++++++++++++++++++++----- src/tag_line.zig | 41 ++ src/themes/atelier.zig | 49 ++ src/themes/clay.zig | 50 ++ src/themes/daybreak.zig | 49 ++ src/themes/dusk.zig | 49 ++ src/themes/forge.zig | 51 ++ src/themes/forge_black.zig | 51 ++ src/themes/forge_soft.zig | 51 ++ src/themes/harvest.zig | 50 ++ src/themes/ink.zig | 49 ++ src/themes/lagoon.zig | 50 ++ src/themes/orchard.zig | 49 ++ src/themes/orchard_black.zig | 49 ++ src/themes/paper.zig | 49 ++ src/themes/solarium.zig | 50 ++ src/themes/spectrum.zig | 50 ++ src/tutor.txt | 14 +- 30 files changed, 3160 insertions(+), 260 deletions(-) create mode 100644 src/gui/pet.zig create mode 100644 src/tag_line.zig create mode 100644 src/themes/atelier.zig create mode 100644 src/themes/clay.zig create mode 100644 src/themes/daybreak.zig create mode 100644 src/themes/dusk.zig create mode 100644 src/themes/forge.zig create mode 100644 src/themes/forge_black.zig create mode 100644 src/themes/forge_soft.zig create mode 100644 src/themes/harvest.zig create mode 100644 src/themes/ink.zig create mode 100644 src/themes/lagoon.zig create mode 100644 src/themes/orchard.zig create mode 100644 src/themes/orchard_black.zig create mode 100644 src/themes/paper.zig create mode 100644 src/themes/solarium.zig create mode 100644 src/themes/spectrum.zig (limited to 'src') diff --git a/src/builtins.zig b/src/builtins.zig index e55a418f..9208b9f8 100644 --- a/src/builtins.zig +++ b/src/builtins.zig @@ -436,6 +436,20 @@ pub const Filter = struct { } }; +/// Switch this terminal between raw program input and normal editor mode. +pub const Togglettymode = struct { + pub fn run(c: Ctx) void { + c.p.toggleTty(c.id); + } +}; + +/// Fold a pane to its tag, or restore its share of the column. +pub const Collapse = struct { + pub fn run(c: Ctx) void { + layout.toggleCollapse(c.p, c.id); + } +}; + pub const Delcol = struct { pub fn run(c: Ctx) void { c.p.removeColumn(c.id) catch |err| c.p.reportError(c.id, "close column", err); @@ -444,7 +458,7 @@ pub const Delcol = struct { /// A shell in the calling pane's directory, raw from the first frame. On every /// pane's tagline: the fast path from wherever you are to a prompt there. -pub const Newtty = struct { +pub const Tty = struct { pub fn run(c: Ctx) void { c.p.spawnTty(c.id); } @@ -509,6 +523,16 @@ pub const EffectCode = struct { } }; +/// An optional SDL workspace-tag companion; never consumes input or text cells. +pub const Pet = struct { + pub const takes_arg = true; + pub const enabled = pardes.platform == .gui; + pub fn run(c: Ctx) void { + const name = std.mem.trim(u8, c.arg orelse return, " \t\r\n"); + c.p.settings.pet = std.meta.stringToEnum(@TypeOf(c.p.settings.pet), name) orelse return; + } +}; + pub const Mini = struct { pub const takes_arg = true; pub const output: OutputTraits = .{ .name = "Mini", .doc = true }; diff --git a/src/config.zig b/src/config.zig index 5dcfef01..282e63da 100644 --- a/src/config.zig +++ b/src/config.zig @@ -43,6 +43,8 @@ pub const leader_path = paths: { .Subtypes = "lT", .Del = "d", .Filter = null, + .Togglettymode = null, + .Collapse = null, .Kill = null, .ClipYank = "y", .ClipYankMain = "Y", @@ -51,7 +53,7 @@ pub const leader_path = paths: { .ClipReplace = "R", .Save = "fs", .New = "fn", - .Newtty = "nt", + .Tty = "nt", .Find = "ff", .Grep = "fg", .Config = "fc", @@ -64,6 +66,9 @@ pub const leader_path = paths: { .Debug = "td", .Msg = null, .Colors = "tc", + .FocusTint = null, + .ColumnTags = null, + .SyntaxBold = null, .Wrap = "tw", .Tagbottom = "tb", .NextColor = "tn", @@ -92,6 +97,7 @@ pub const leader_path = paths: { table.set(.Font, null); table.set(.TaglineSize, null); } + if (pardes.platform == .gui) table.set(.Pet, null); if (builtins.capabilities.panel_transitions) { table.set(.PanelSlide, "as"); table.set(.PanelZoom, "az"); @@ -215,13 +221,12 @@ pub const tty_blank: enum { prompt, prompt_and_input } = .prompt; // Projected colors below this contrast use the theme's more legible anchor. pub const tty_filter_min_contrast: f64 = 1.5; -pub const topbar_str = "New Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Kill"; +pub const topbar_str = "Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Kill"; -pub const pane_builtins_str = "New Newtty Del"; -pub const file_pane_builtins_str = "Save New Newtty Del"; -pub const terminal_pane_builtins_str = "Save New Newtty Del Filter"; - -pub const tag_right_pad: u16 = 20; +pub const pane_builtins_str = "Tty Del Collapse"; +pub const pdf_pane_builtins_str = "Tty Del PdfSections PdfTint Collapse"; +pub const file_pane_builtins_str = "Save Tty Del Collapse"; +pub const terminal_pane_builtins_str = "Save Tty Del Togglettymode Filter Collapse"; // Each mode badge is one codepoint. pub const tag_normal = " "; @@ -529,6 +534,10 @@ pub const Runtime = struct { wrap: bool = true, tag_bottom: bool = false, debug: bool = false, + focus_tint: bool = true, + column_tags: bool = true, + syntax_bold: bool = false, + pet: enum { off, cat, frog } = .off, // Effective values change only after a host acknowledges the request. shell: struct { @@ -540,6 +549,7 @@ pub const Runtime = struct { font: struct { requested_path: Text(limits.host_path_cap) = .{}, requested_name: Text(255) = .{}, + requested_size_hundredths: u16 = 0, // zero preserves the renderer's current size effective_name: Text(255) = .{}, pending: bool = false, effective_size_hundredths: u16 = 0, @@ -582,12 +592,30 @@ pub const Runtime = struct { pub const FontSizeUnit = enum { unknown, pixels, points }; - // Validate both strings before changing either member of the request. - pub fn requestFont(state: *Runtime, path: []const u8, name: []const u8) bool { + pub const FontSpec = struct { + name: []const u8, + size_hundredths: u16 = 0, + + pub fn parse(argument: []const u8) ?FontSpec { + const text = std.mem.trim(u8, argument, " \t\r\n"); + if (text.len == 0) return null; + const colon = std.mem.indexOfScalar(u8, text, ':') orelse return .{ .name = text }; + const name = std.mem.trim(u8, text[0..colon], " \t"); + if (name.len == 0) return null; + const size = std.fmt.parseFloat(f32, text[colon + 1 ..]) catch return null; + if (!std.math.isFinite(size) or size < 8 or size > 72) return null; + return .{ .name = name, .size_hundredths = @intFromFloat(@round(size * 100)) }; + } + }; + + // Validate the complete request before changing any member. + pub fn requestFont(state: *Runtime, path: []const u8, name: []const u8, size_hundredths: u16) bool { if (path.len > state.font.requested_path.bytes.len or name.len > state.font.requested_name.bytes.len) return false; + if (size_hundredths != 0 and (size_hundredths < 800 or size_hundredths > 7200)) return false; std.debug.assert(state.font.requested_path.set(path)); std.debug.assert(state.font.requested_name.set(name)); + state.font.requested_size_hundredths = size_hundredths; state.font.pending = true; return true; } @@ -601,7 +629,7 @@ pub const Runtime = struct { pub const Capability = std.meta.FieldEnum(Capabilities); - pub const Toggle = enum { colors, wrap, tag_bottom, debug }; + pub const Toggle = enum { colors, wrap, tag_bottom, debug, focus_tint, column_tags, syntax_bold }; pub const Scene = std.meta.FieldEnum(layout.SceneEffect); pub const Action = union(enum) { @@ -640,6 +668,9 @@ pub const Runtime = struct { .{ .word = "Wrap", .action = .{ .toggle = .wrap } }, .{ .word = "Tagbottom", .action = .{ .toggle = .tag_bottom } }, .{ .word = "Debug", .action = .{ .toggle = .debug } }, + .{ .word = "FocusTint", .action = .{ .toggle = .focus_tint } }, + .{ .word = "ColumnTags", .action = .{ .toggle = .column_tags } }, + .{ .word = "SyntaxBold", .action = .{ .toggle = .syntax_bold } }, .{ .word = "Theme", .action = .theme }, .{ .word = "Shell", .action = .shell }, .{ .word = "Font", .action = .font, .availability = .font_picker }, @@ -718,6 +749,9 @@ pub const Runtime = struct { .wrap => state.wrap = !state.wrap, .tag_bottom => state.tag_bottom = !state.tag_bottom, .debug => state.debug = !state.debug, + .focus_tint => state.focus_tint = !state.focus_tint, + .column_tags => state.column_tags = !state.column_tags, + .syntax_bold => state.syntax_bold = !state.syntax_bold, }, .shell => { const value = std.mem.trim(u8, argument orelse return false, " \t\r\n"); @@ -778,6 +812,9 @@ pub const Runtime = struct { .wrap => state.wrap, .tag_bottom => state.tag_bottom, .debug => state.debug, + .focus_tint => state.focus_tint, + .column_tags => state.column_tags, + .syntax_bold => state.syntax_bold, }; try out.print("{s}: {s}\n", .{ setting.word, onOff(value) }); }, @@ -801,7 +838,11 @@ pub const Runtime = struct { }, .font => if (!setting.enabled(context.capabilities)) try out.print("{s}: unsupported\n", .{setting.word}) - else + else { + if (state.font.requested_size_hundredths == 0) + try out.print("{s} requested size: unchanged\n", .{setting.word}) + else + try out.print("{s} requested size: {d}.{d:0>2}\n", .{ setting.word, state.font.requested_size_hundredths / 100, state.font.requested_size_hundredths % 100 }); try out.print( "{s} requested: {s}\n" ++ "{s} requested path: {s}\n" ++ @@ -822,7 +863,8 @@ pub const Runtime = struct { state.font.effective_size_hundredths % 100, @tagName(state.font.effective_size_unit), }, - ), + ); + }, .tagline_size => if (!context.capabilities.tagline_font_size) try out.print("{s}: unsupported\n", .{setting.word}) else if (!setting.enabled(context.capabilities)) @@ -865,6 +907,8 @@ pub const Runtime = struct { else try out.writeAll("Look hover delay: off\n"); try out.print("Native images: {s}\n", .{onOff(context.native_images)}); + if (std.mem.eql(u8, context.platform, "gui")) + try out.print("Pet: {s}\n", .{@tagName(state.pet)}); } test "setting names are unique and argument metadata follows actions" { @@ -912,11 +956,26 @@ pub const Runtime = struct { test "font request tuple rejects atomically" { var state: Runtime = .{}; - try std.testing.expect(requestFont(&state, "/fonts/old.ttf", "Old")); + try std.testing.expect(requestFont(&state, "/fonts/old.ttf", "Old", 1200)); var too_long: [256]u8 = @splat('x'); - try std.testing.expect(!requestFont(&state, "/fonts/new.ttf", &too_long)); + try std.testing.expect(!requestFont(&state, "/fonts/new.ttf", &too_long, 1800)); + try std.testing.expect(!requestFont(&state, "/fonts/new.ttf", "New", 799)); try std.testing.expectEqualStrings("/fonts/old.ttf", state.font.requested_path.get()); try std.testing.expectEqualStrings("Old", state.font.requested_name.get()); + try std.testing.expectEqual(@as(u16, 1200), state.font.requested_size_hundredths); + } + + test "font specification accepts optional size and rejects invalid suffixes" { + const plain = FontSpec.parse(" Example Mono ").?; + try std.testing.expectEqualStrings("Example Mono", plain.name); + try std.testing.expectEqual(@as(u16, 0), plain.size_hundredths); + const sized = FontSpec.parse("Example Mono:12.5").?; + try std.testing.expectEqualStrings("Example Mono", sized.name); + try std.testing.expectEqual(@as(u16, 1250), sized.size_hundredths); + try std.testing.expectEqual(@as(u16, 800), FontSpec.parse("Mono:8").?.size_hundredths); + try std.testing.expectEqual(@as(u16, 7200), FontSpec.parse("Mono:72").?.size_hundredths); + for ([_][]const u8{ "", ":12", "Mono:", "Mono:abc", "Mono:0", "Mono:-12", "Mono:7.99", "Mono:72.01", "Mono:nan", "Mono:inf", "Mono:12:13" }) |bad| + try std.testing.expect(FontSpec.parse(bad) == null); } test "Config report observes every simple setting and all live context" { @@ -960,6 +1019,9 @@ pub const Runtime = struct { .wrap => "Wrap: off\n", .tag_bottom => "Tagbottom: on\n", .debug => "Debug: on\n", + .focus_tint => "FocusTint: off\n", + .column_tags => "ColumnTags: off\n", + .syntax_bold => "SyntaxBold: on\n", }, .shell => "Shell requested (new panes): fish\n", .tagline_size => "TaglineSize: 73%\n", @@ -989,6 +1051,7 @@ pub const Runtime = struct { try std.testing.expect(state.font.requested_name.set("Wanted Mono")); try std.testing.expect(state.font.requested_path.set("/fonts/wanted.ttf")); + state.font.requested_size_hundredths = 1850; try std.testing.expect(state.font.effective_name.set("Effective Mono")); state.font.pending = true; state.font.effective_size_hundredths = 1375; @@ -1000,6 +1063,7 @@ pub const Runtime = struct { const report = storage[0..out.end]; for ([_][]const u8{ "Theme: acme\n", + "Font requested size: 18.50\n", "Font requested: Wanted Mono\n", "Font requested path: /fonts/wanted.ttf\n", "Font effective: Effective Mono\n", diff --git a/src/dump.zig b/src/dump.zig index 93bf08cc..ff46205f 100644 --- a/src/dump.zig +++ b/src/dump.zig @@ -109,6 +109,7 @@ pub const Pane = struct { cols: u16 = 0, rows: u16 = 0, vweight: f32 = 1, + collapsed: bool = false, /// Exact editable tail when the user touched it. Null retains the live /// default; a present empty slice deliberately restores an empty tail. tag_tail: ?[]const u8 = null, @@ -118,6 +119,7 @@ pub const Pane = struct { }; pub const Column = struct { + tag: ?[]const u8 = null, weight: f32 = 1, panes: []const usize = &.{}, }; @@ -130,13 +132,20 @@ pub const State = struct { screen: Size, active: usize = 0, topbar: []const u8 = "", + topbar_custom: ?[]const u8 = null, theme: []const u8 = "dark", columns: []const Column = &.{}, panes: []const Pane = &.{}, mounts: []const Mount = &.{}, }; +fn validateTag(bar: []const u8) !void { + if (bar.len > limits.max_tag_tail or std.mem.indexOfAny(u8, bar, "\r\n\x00") != null or !std.unicode.utf8ValidateSlice(bar)) return error.BadDumpTag; +} + pub fn validate(state: State) !void { + if (state.topbar_custom) |bar| try validateTag(bar); + for (state.columns) |column| if (column.tag) |bar| try validateTag(bar); if (!std.mem.eql(u8, state.magic, magic)) return error.BadDumpMagic; if (state.version != version) return error.BadDumpVersion; if (state.panes.len == 0 or state.panes.len > max_panes) return error.BadDumpPanes; diff --git a/src/fs.zig b/src/fs.zig index 50b56f76..63d2ed3a 100644 --- a/src/fs.zig +++ b/src/fs.zig @@ -2911,6 +2911,36 @@ fn writeCtl(p: *Pardes, req: Req, serial: u32) Reply { return .{ .tag = req.tag, .written = @intCast(req.data.len) }; } +/// Change a buffer's absolute name without writing or renaming a disk file. +/// Tag editing preserves user commands; the 9P name operation resets its tail. +pub fn nameBuffer(p: *Pardes, id: usize, full: []const u8, preserve_tag: bool) !void { + const pane = p.panes[id] orelse return error.NoPane; + const f = fileOf(pane) orelse return error.NotFile; + if (!std.fs.path.isAbsolute(full) or full.len == 0 or full.len >= 4096) + return error.InvalidName; + for (full) |c| if (c < ' ') return error.InvalidName; + if (std.mem.eql(u8, f.path, full)) return; + const copy = try p.gpa.dupe(u8, full); + p.gpa.free(f.path); + f.path = copy; + if (panes.Output.fileTraits(f.output).saves) { + f.output = null; + pane.clearCwd(); + if (!preserve_tag) { + pane.tag_init = false; + pane.tag_tail_len = 0; + } + f.saved_revision = f.revision -% 1; + f.watch_after_save = localPath(full) != null; + } + if (f.highlights.len > 0) p.tree_sitter_gpa.free(f.highlights); + f.highlights = &.{}; + f.highlight_start = 0; + f.syntax_dirty = true; + p.invalidateLookHover(id); + p.emit(.{ .watch = .{ .pane = @intCast(id), .on = false } }); +} + fn ctlVerb(p: *Pardes, id: usize, line: []const u8, apply: bool, dirty: *bool) u16 { const pane = p.panes[id] orelse return E.INVAL; const pf = &p.fs.panes[id]; @@ -2935,23 +2965,7 @@ fn ctlVerb(p: *Pardes, id: usize, line: []const u8, apply: bool, dirty: *bool) u if (std.mem.eql(u8, f.path, full)) return 0; if (panes.Output.fileTraits(f.output).saves) dirty.* = true; if (!apply) return 0; - const copy = p.gpa.dupe(u8, full) catch return E.NOMEM; - p.gpa.free(f.path); - f.path = copy; - if (panes.Output.fileTraits(f.output).saves) { - f.output = null; - pane.clearCwd(); - pane.tag_init = false; - pane.tag_tail_len = 0; - f.saved_revision = f.revision -% 1; - f.watch_after_save = localPath(full) != null; - } - if (f.highlights.len > 0) p.tree_sitter_gpa.free(f.highlights); - f.highlights = &.{}; - f.highlight_start = 0; - f.syntax_dirty = true; - p.invalidateLookHover(id); - p.emit(.{ .watch = .{ .pane = @intCast(id), .on = false } }); + nameBuffer(p, id, full, false) catch return E.NOMEM; return 0; } for (refused_verbs) |w| if (verbIs(line, w)) return E.INVAL; @@ -3711,7 +3725,8 @@ test "index prints winctlprint's five fields then the tag" { @memcpy(got[0..a.bytes.len], a.bytes); const line = got[0..a.bytes.len]; - const tag = tagOf(p, pane); + const tag = try gpa.dupe(u8, tagOf(p, pane)); + defer gpa.free(tag); var want: std.ArrayList(u8) = .empty; defer want.deinit(gpa); try want.print(gpa, "{d:>11} {d:>11} {d:>11} {d:>11} {d:>11} {s}\n", .{ diff --git a/src/gui/gui.zig b/src/gui/gui.zig index f7b9fc7e..0b2bdd18 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -12,6 +12,7 @@ const message = pardes.Pardes.Message; const file_watch = @import("../file_watch.zig"); const deck = @import("deck.zig"); const crt = @import("crt.zig"); +const pet = @import("pet.zig"); const fonts = @import("../fonts.zig"); // the Font builtin's half of the seam const selection_pipe = @import("../selection_pipe.zig"); @@ -82,6 +83,22 @@ const Slot = struct { u: u32, v: u32 }; const GlyphKey = struct { codepoint: u32, role: pardes.FontRole, + decoration: GlyphDecoration = .{}, +}; + +const GlyphDecoration = struct { + bold: bool = false, + underline: @FieldType(pardes.CellStyle, "ul") = .off, + strikethrough: bool = false, + + fn fromCell(cell: *const pardes.Cell) GlyphDecoration { + if (cell.default or cell.style.invisible) return .{}; + return .{ .bold = cell.style.bold, .underline = cell.style.ul, .strikethrough = cell.style.strikethrough }; + } + + fn drawsBlank(decoration: GlyphDecoration) bool { + return decoration.underline != .off or decoration.strikethrough; + } }; const TaglineRaster = struct { @@ -191,7 +208,9 @@ test "a persistent scene presents a new panel's frame zero before advancing it" for (second.panelTracks()) |track| try std.testing.expectEqual(@as(u16, 1), track.frame); } -const max_overlay_vertices: usize = 18 + touch_click_flash_vertices + max_touch_points * (1100 + max_touch_trail_points * overlay_circle_vertices); +// Per pane: background, rule, two grip strokes, and two square rails. +const pane_chrome_vertices: usize = 6 * 6; +const max_overlay_vertices: usize = 24 + pet.max_vertices + pardes.MAX_COLS * 6 + pardes.MAX_PANES * pane_chrome_vertices + touch_click_flash_vertices + max_touch_points * (1100 + max_touch_trail_points * overlay_circle_vertices); const font_px_step: f32 = 2.0; const font_px_min: f32 = 8.0; @@ -238,15 +257,25 @@ test "FreeType atlas raster is grayscale, bounded, and clears blank cells" { var ink: usize = 0; var soft: usize = 0; + var regular_coverage: usize = 0; for (0..@intCast(cell_h)) |y| { for (0..@intCast(cell_w)) |x| { const coverage = bitmap[y * @as(usize, @intCast(stride)) + x]; if (coverage > 0) ink += 1; if (coverage > 0 and coverage < 255) soft += 1; + regular_coverage += coverage; } } try std.testing.expect(ink > 0); try std.testing.expect(soft > 0); + decorateGlyph(&bitmap, @intCast(stride), @intCast(cell_w), @intCast(cell_h), ascent, .{ .bold = true }); + var bold_coverage: usize = 0; + for (0..@intCast(cell_h)) |y| { + for (0..@intCast(cell_w)) |x| bold_coverage += bitmap[y * @as(usize, @intCast(stride)) + x]; + for (bitmap[y * @as(usize, @intCast(stride)) + @as(usize, @intCast(cell_w)) ..][0..@intCast(stride - cell_w)]) |guard| + try std.testing.expectEqual(@as(u8, 0xaa), guard); + } + try std.testing.expect(bold_coverage > regular_coverage); try std.testing.expectEqual( @as(c_int, 0), @@ -353,7 +382,8 @@ test "glyph atlas cache separates body and tagline roles" { defer glyphs.deinit(); try glyphs.put(.{ .codepoint = 'A', .role = .body }, .{ .u = 0, .v = 0 }); try glyphs.put(.{ .codepoint = 'A', .role = .tagline }, .{ .u = 10, .v = 0 }); - try std.testing.expectEqual(@as(usize, 2), glyphs.count()); + try glyphs.put(.{ .codepoint = 'A', .role = .body, .decoration = .{ .bold = true } }, .{ .u = 20, .v = 0 }); + try std.testing.expectEqual(@as(usize, 3), glyphs.count()); try std.testing.expectEqual(@as(u32, 0), glyphs.get(.{ .codepoint = 'A', .role = .body }).?.u); try std.testing.expectEqual(@as(u32, 10), glyphs.get(.{ .codepoint = 'A', .role = .tagline }).?.u); } @@ -1735,6 +1765,7 @@ const Gui = struct { space_slot: Slot = .{ .u = 0, .v = 0 }, touch: Touch = .{}, + pet_state: pet.State = .{}, live_ctrl: bool = false, live_alt: bool = false, @@ -3674,6 +3705,8 @@ fn pollFrame(ctx: ?*anyopaque) void { if (g.font_bytes.len != 0) s.gpa.free(g.font_bytes); g.font = nf; g.font_bytes = bytes; // FreeType borrows them for the face lifetime + if (core.settings.font.requested_size_hundredths != 0) + g.px = @as(f32, @floatFromInt(core.settings.font.requested_size_hundredths)) / 100; setGuiFontName(g, core.settings.font.requested_name.get()); refitFont(g, core); acknowledgeGuiFont(g, core); @@ -3699,7 +3732,7 @@ fn present(ctx: ?*anyopaque, surface: *const pardes.Surface) void { core, frame, core.theme().bg, - config.gui_topbar_pane_border_rgb orelse core.chromeTheme().scroll_track, + config.gui_topbar_pane_border_rgb orelse core.chromeTheme().border, core.chromeTheme().tag_bg, core.settings.scene_effects, core.settings.debug, @@ -5065,8 +5098,9 @@ fn resolveCell(g: *Gui, cell: *const pardes.Cell, role: pardes.FontRole, is_curs } const cp = if (blank) ' ' else cellCodepoint(cell); + const decoration = GlyphDecoration.fromCell(cell); return .{ - .slot = if (cp == ' ') g.space_slot else ensureGlyph(g, cp, role), + .slot = if (cp == ' ' and !decoration.drawsBlank()) g.space_slot else ensureGlyph(g, cp, role, decoration), .fg = fg, .bg = bg, .role = role, @@ -5088,6 +5122,39 @@ fn cellInstanceCount(core: ?*const pardes.Pardes, cell: *const pardes.Cell, row: return if (drawnFontRole(core, cell, row) == .tagline) 2 else 1; } +fn taglineBaseRgb(core: *const pardes.Pardes, col: u16, row: u16) [3]u8 { + const chrome = core.chromeTheme(); + if (!core.settings.focus_tint) return chrome.tag_bg; + if (row >= pardes.TOPBAR_H and row < pardes.TOPBAR_H + core.columnBarHeight()) { + for (0..core.ncol) |column| for (core.col_panes[column][0..core.col_n[column]]) |id| { + if (id != core.active) continue; + const x = core.col_x[column]; + if (col >= x and col - x < core.col_w[column]) return chrome.tag_active_bg; + }; + } + const r = core.rects[core.active]; + const tag_y = if (core.settings.tag_bottom) r.y + r.h -| pardes.BOX_H else r.y; + return if (r.h > 0 and row == tag_y and col >= r.x and col - r.x < r.w) chrome.tag_active_bg else chrome.tag_bg; +} + +test "compact column and pane underlays share focus tint without tinting the global tag" { + const core = try pardes.Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 24 }); + defer core.deinit(); + _ = try core.newShell(1, ""); + try std.testing.expect(pardes.layout.splitColumn(core, 0, 1, false)); + pardes.test_api.sync(core); + core.active = 1; + const active = core.rects[1]; + const inactive = core.rects[0]; + const chrome = core.chromeTheme(); + try std.testing.expectEqual(chrome.tag_bg, taglineBaseRgb(core, active.x, 0)); + try std.testing.expectEqual(chrome.tag_active_bg, taglineBaseRgb(core, active.x, pardes.TOPBAR_H)); + try std.testing.expectEqual(chrome.tag_bg, taglineBaseRgb(core, inactive.x, pardes.TOPBAR_H)); + try std.testing.expectEqual(chrome.tag_active_bg, taglineBaseRgb(core, active.x, active.y)); + core.settings.focus_tint = false; + try std.testing.expectEqual(chrome.tag_bg, taglineBaseRgb(core, active.x, pardes.TOPBAR_H)); +} + fn emitSurfaceCell( g: *Gui, core: ?*const pardes.Pardes, @@ -5107,7 +5174,12 @@ fn emitSurfaceCell( ) void { const role = drawnFontRole(core, cell, row); if (role == .tagline) { - emitInstance(g, instances, next.*, col, row, body_layout, win_w, win_h, track, .tagline, tagline_base, old_layer, false, page); + var band_base = tagline_base.*; + if (core) |p| band_base.style.bg = .{ .rgb = taglineBaseRgb(p, col, row) }; + // The smaller font changes the ink, not the chrome's coverage. Painting + // this underlay at tagline height leaves page-colored gutters between + // adjacent workspace, column and pane tags at TaglineSize < 100. + emitInstance(g, instances, next.*, col, row, body_layout, win_w, win_h, track, .body, &band_base, old_layer, false, page); next.* += 1; const tag_layout = taglineLayoutForCell(g, core, col, row, track); emitInstance(g, instances, next.*, col, row, tag_layout, win_w, win_h, track, .tagline, cell, old_layer, is_cursor, page); @@ -5118,6 +5190,29 @@ fn emitSurfaceCell( next.* += 1; } +test "small tagline fonts leave no background gutters between adjacent tags" { + const core = try pardes.Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 }); + defer core.deinit(); + var g: Gui = undefined; + g.cell_w = 10; + g.cell_h = 20; + g.tagline_width = 8; + g.tagline_height = 16; + g.space_slot = .{ .u = 0, .v = 0 }; + const cell: pardes.Cell = .{ .default = false, .style = .{ .font_role = .tagline } }; + var instances: [6]CellInstance = undefined; + var next: u32 = 0; + for (0..3) |row| { + emitSurfaceCell(&g, core, &instances, &next, 0, @intCast(row), fixedCellLayout(&g), 800, 480, null, &cell, &cell, false, false, Ground.opaqueRgb(bg_default)); + const base = instances[row * 2]; + const ink = instances[row * 2 + 1]; + try std.testing.expectApproxEqAbs(@as(f32, 20.0 / 480.0 * 2), base.y0 - base.y1, 0.0001); + try std.testing.expectApproxEqAbs(@as(f32, 16.0 / 480.0 * 2), ink.y0 - ink.y1, 0.0001); + try std.testing.expect(ink.y0 <= base.y0 and ink.y1 >= base.y1); + if (row > 0) try std.testing.expectApproxEqAbs(instances[(row - 1) * 2].y1, base.y0, 0.0001); + } +} + fn emitInstance( g: *Gui, instances: [*]CellInstance, @@ -5219,10 +5314,11 @@ test "insert cursor overlays without replacing the character beneath it" { .y_off = 0, .w = 10, .h = 20, - }); + }, .{ 32, 48, 64 }); try std.testing.expectEqual(@as(usize, 6), builder.len); try std.testing.expectApproxEqAbs(@as(f32, -0.32), vertices[0].y, 0.0001); try std.testing.expectApproxEqAbs(@as(f32, -0.48), vertices[2].y, 0.0001); + try std.testing.expectApproxEqAbs(@as(f32, 32.0 / 255.0), vertices[0].r, 0.0001); } fn palColor(idx: u8) [3]u8 { @@ -5284,8 +5380,8 @@ fn syncTaglineFont(g: *Gui, core: *const pardes.Pardes) void { resetGlyphAtlas(g); } -fn ensureGlyph(g: *Gui, cp: u32, role: pardes.FontRole) Slot { - const key: GlyphKey = .{ .codepoint = cp, .role = role }; +fn ensureGlyph(g: *Gui, cp: u32, role: pardes.FontRole, decoration: GlyphDecoration) Slot { + const key: GlyphKey = .{ .codepoint = cp, .role = role, .decoration = decoration }; if (g.glyphs.get(key)) |s| return s; if (g.pen_x + g.cell_w > atlas_w) { g.pen_x = 0; @@ -5300,12 +5396,82 @@ fn ensureGlyph(g: *Gui, cp: u32, role: pardes.FontRole) Slot { const raster_w = if (role == .tagline) g.tagline_width else g.cell_w; const raster_h = if (role == .tagline) g.tagline_height else g.cell_h; _ = c.ui_font_raster(face, scale, @intCast(cp), out, @intCast(atlas_w), @intCast(raster_w), @intCast(raster_h), baseline); + decorateGlyph(out, atlas_w, raster_w, raster_h, baseline, decoration); g.glyphs.put(key, s) catch return g.space_slot; g.pen_x += g.cell_w; g.atlas_dirty = true; return s; } +// Decorations stay inside the existing atlas slot, so text advance, caret and +// selection geometry are identical to the regular glyph. The cached mask keeps +// this work out of steady-state rendering and also decorates terminal spaces. +fn decorateGlyph(out: [*]u8, stride: usize, width: usize, height: usize, baseline: i32, decoration: GlyphDecoration) void { + if (width == 0 or height == 0) return; + if (decoration.bold) { + // A fractional one-pixel embolden preserves counters at small UI sizes. + // Right-to-left traversal reads the original left neighbour each time. + for (0..height) |y| { + var x = width - 1; + while (x > 0) : (x -= 1) { + const previous = out[y * stride + x - 1]; + const target = &out[y * stride + x]; + target.* = @max(target.*, @as(u8, @intCast(@as(u16, previous) * 3 / 4))); + } + } + } + if (decoration.underline != .off) { + const base: usize = @intCast(@max(0, baseline)); + const row = @min(base + 1, height - 1); + for (0..width) |x| { + switch (decoration.underline) { + .off => unreachable, + .single => out[row * stride + x] = 255, + .double => { + const upper = @min(base + 1, height -| 3); + out[upper * stride + x] = 255; + out[@min(upper + 2, height - 1) * stride + x] = 255; + }, + .curly => out[(row -| @as(usize, if (x % 4 < 2) 1 else 0)) * stride + x] = 255, + .dotted => if (x % 3 == 0) { + out[row * stride + x] = 255; + }, + .dashed => if (x % 6 < 4) { + out[row * stride + x] = 255; + }, + } + } + } + if (decoration.strikethrough) { + const row = @min(height - 1, @as(usize, @intCast(@max(0, baseline))) * 2 / 3); + @memset(out[row * stride ..][0..width], 255); + } +} + +test "glyph decorations render spaces and stay inside their atlas slot" { + const stride = 9; + const width = 5; + const height = 7; + for ([_]GlyphDecoration{ + .{ .underline = .single }, + .{ .underline = .double }, + .{ .underline = .curly }, + .{ .underline = .dotted }, + .{ .underline = .dashed }, + .{ .strikethrough = true }, + }) |decoration| { + var mask: [stride * (height + 1)]u8 = @splat(0xa5); + for (0..height) |y| @memset(mask[y * stride ..][0..width], 0); + decorateGlyph(&mask, stride, width, height, 5, decoration); + try std.testing.expect(inkBounds(&mask, stride, width, height) != null); + for (0..height) |y| for (mask[y * stride + width ..][0 .. stride - width]) |guard| + try std.testing.expectEqual(@as(u8, 0xa5), guard); + for (mask[stride * height ..]) |guard| try std.testing.expectEqual(@as(u8, 0xa5), guard); + } + const hidden: pardes.Cell = .{ .default = false, .style = .{ .invisible = true, .ul = .single } }; + try std.testing.expect(!GlyphDecoration.fromCell(&hidden).drawsBlank()); +} + fn uploadAtlas(g: *Gui, cmd: *c.SDL_GPUCommandBuffer) void { const ptr = c.SDL_MapGPUTransferBuffer(g.device, g.atlas_xfer, false) orelse return; const dst: [*]u8 = @ptrCast(ptr); @@ -5709,6 +5875,7 @@ fn addCursorBar( bar: bool, visual_height: f32, layout: CellLayout, + rgb: [3]u8, ) void { if (!bar) return; const x0 = layout.x_off + @as(f32, @floatFromInt(x)) * layout.w; @@ -5721,9 +5888,9 @@ fn addCursorBar( x0 + @max(1.0, layout.w / 8.0), y0 + height, .{ - .r = @as(f32, @floatFromInt(fg_default[0])) / 255.0, - .g = @as(f32, @floatFromInt(fg_default[1])) / 255.0, - .b = @as(f32, @floatFromInt(fg_default[2])) / 255.0, + .r = @as(f32, @floatFromInt(rgb[0])) / 255.0, + .g = @as(f32, @floatFromInt(rgb[1])) / 255.0, + .b = @as(f32, @floatFromInt(rgb[2])) / 255.0, .a = 1.0, }, ); @@ -5745,6 +5912,7 @@ fn buildOverlay( const win_w: f32 = @floatFromInt(sw); const win_h: f32 = @floatFromInt(sh); var builder = OverlayBuilder{ .vertices = g.overlay_vertices, .win_w = win_w, .win_h = win_h }; + appendWorkspacePet(g, core, surface, &builder); if (topbarPaneBorderHeight(surface, g.cell_h, g.tagline_height)) |height| { const y0: f32 = @floatFromInt(g.cell_h - height / 2); const rgb = topbar_pane_border_rgb; @@ -5755,6 +5923,10 @@ fn buildOverlay( .a = 1.0, }); } + if (core) |p| if (p.columnBarHeight() > 0) { + const y = @as(f32, @floatFromInt(pardes.TOPBAR_H + p.columnBarHeight())) * layout.h; + builder.addRect(0, y - 1, win_w, y, overlayRgb(p.chromeTheme().border)); + }; const grid_bottom = @as(f32, @floatFromInt(surface.rows)) * layout.h; if (grid_bottom < win_h and bottomTaglinePresent(surface)) { const rgb = tagline_rgb; @@ -5766,6 +5938,7 @@ fn buildOverlay( }); } if (transient_on) { + if (core) |p| appendPaneChrome(g, p, surface, layout, &builder); if (surface.cursor) |cursor| { if (cursor.x < surface.cols and cursor.y < surface.rows) { const cell = surface.cells[@as(usize, cursor.y) * surface.cols + cursor.x]; @@ -5778,7 +5951,7 @@ fn buildOverlay( taglineLayoutForCell(g, core, cursor.x, cursor.y, null) else layout; - addCursorBar(&builder, cursor.x, cursor.y, cursor.bar, visual_height, cursor_layout); + addCursorBar(&builder, cursor.x, cursor.y, cursor.bar, visual_height, cursor_layout, cursorColor(core, &cell, role)); } } } @@ -5786,6 +5959,244 @@ fn buildOverlay( return @intCast(builder.len); } +fn appendWorkspacePet(g: *Gui, core: ?*const pardes.Pardes, surface: *const pardes.Surface, builder: *OverlayBuilder) void { + const p = core orelse return; + const kind: pet.Kind = @enumFromInt(@intFromEnum(p.settings.pet)); + if (kind == .off or surface.rows == 0 or surface.cols == 0) { + g.pet_state = .{}; + return; + } + // Read only the rendered global row. No pane/column tags, status text or + // mouse targets are changed, and editing the workspace parks the pet. + var occupied: usize = 0; + for (surface.cells[0..surface.cols], 0..) |*cell, i| { + if (cellCodepoint(cell) != ' ') occupied = i + 1; + } + const editing = p.topbar_col != null and p.header_column == null; + const band_top: f32 = @floatFromInt(taglineBandOffset(0, builder.win_h, g.cell_h, g.tagline_height)); + const region = if (occupied >= surface.cols) null else pet.lane( + occupied, + @floatFromInt(g.tagline_width), + builder.win_w, + band_top, + @floatFromInt(g.tagline_height), + editing, + ); + // SDL's regular pump already wakes at least every 16 ms. Quantization in + // sample keeps this independent of input, render rate and scene effects, + // without creating another timer or forcing core animation ticks. + const pose = g.pet_state.sample(kind, c.SDL_GetTicks(), region) orelse return; + const ink = overlayRgb(p.chromeTheme().tag_fg); + const eye = overlayRgb(p.chromeTheme().tag_bg); + for (0..pet.height) |y| for (0..pet.width) |x| { + const color = pet.pixel(kind, pose, x, y); + if (color == 0) continue; + const x0 = pose.x + @as(f32, @floatFromInt(x)) * pose.scale; + const y0 = pose.y + @as(f32, @floatFromInt(y)) * pose.scale; + builder.addRect(x0, y0, x0 + pose.scale, y0 + pose.scale, if (color == 2) eye else ink); + }; +} + +test "pet overlay stays beyond workspace text and vanishes while its tag is edited" { + std.testing.refAllDecls(pet); + const core = try pardes.Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 }); + defer core.deinit(); + core.settings.pet = .cat; + var cells: [80]pardes.Cell = @splat(.{}); + const surface: pardes.Surface = .{ .cols = 80, .rows = 1, .cells = &cells }; + for ("Workspace Commands", 0..) |ch, i| { + cells[i].text[0] = ch; + cells[i].len = 1; + cells[i].default = false; + } + var g: Gui = undefined; + g.pet_state = .{}; + g.cell_h = 20; + g.tagline_height = 16; + g.tagline_width = 8; + var vertices: [pet.max_vertices]OverlayVertex = undefined; + var builder: OverlayBuilder = .{ .vertices = &vertices, .win_w = 800, .win_h = 480 }; + appendWorkspacePet(&g, core, &surface, &builder); + try std.testing.expect(builder.len > 0); + for (vertices[0..builder.len]) |v| { + const x = (v.x + 1) * 400; + const y = (1 - v.y) * 240; + try std.testing.expect(x >= 20 * 8 - 0.01); + try std.testing.expect(y >= 0 and y < 20); + } + core.topbar_col = 0; + core.header_column = null; + builder.len = 0; + appendWorkspacePet(&g, core, &surface, &builder); + try std.testing.expectEqual(@as(usize, 0), builder.len); +} + +fn cursorColor(core: ?*const pardes.Pardes, cell: *const pardes.Cell, role: pardes.FontRole) [3]u8 { + if (core) |p| return if (role == .tagline) switch (cell.style.fg) { + .rgb => |rgb| rgb, + else => p.chromeTheme().tag_fg, + } else p.theme().fg orelse fg_default; + return switch (cell.style.fg) { + .rgb => |rgb| rgb, + .index => |index| palColor(index), + // Detached frames carry explicit page backgrounds but blank cells need + // not carry foreground ink. Keep their insertion caret visible too. + .default => switch (cell.style.bg) { + .rgb => |rgb| if ((@as(u16, rgb[0]) * 3 + @as(u16, rgb[1]) * 6 + rgb[2]) / 10 > 140) + .{ 32, 32, 32 } + else + fg_default, + else => fg_default, + }, + }; +} + +test "detached insertion caret contrasts with blank light and dark pages" { + var cell: pardes.Cell = .{ .default = false, .style = .{ .bg = .{ .rgb = .{ 248, 246, 237 } } } }; + try std.testing.expectEqual([3]u8{ 32, 32, 32 }, cursorColor(null, &cell, .body)); + cell.style.bg = .{ .rgb = .{ 24, 28, 32 } }; + try std.testing.expectEqual(fg_default, cursorColor(null, &cell, .body)); + cell.style.fg = .{ .rgb = .{ 122, 188, 160 } }; + try std.testing.expectEqual([3]u8{ 122, 188, 160 }, cursorColor(null, &cell, .body)); +} + +fn overlayRgb(rgb: [3]u8) OverlayColor { + return .{ + .r = @as(f32, @floatFromInt(rgb[0])) / 255.0, + .g = @as(f32, @floatFromInt(rgb[1])) / 255.0, + .b = @as(f32, @floatFromInt(rgb[2])) / 255.0, + .a = 1, + }; +} + +fn cellBackgroundIs(cell: *const pardes.Cell, rgb: [3]u8) bool { + return !cell.default and switch (cell.style.bg) { + .rgb => |bg| std.mem.eql(u8, &bg, &rgb), + else => false, + }; +} + +// The canonical surface still owns scrollbar positions and hit targets. Only +// their pixel ink is inset here; no layout, selection or mouse coordinates move. +fn appendPaneChrome( + g: *const Gui, + core: *const pardes.Pardes, + surface: *const pardes.Surface, + layout: CellLayout, + builder: *OverlayBuilder, +) void { + const chrome = core.chromeTheme(); + const page = overlayRgb(core.theme().bg orelse bg_default); + for (core.panes, core.rects) |slot, r| { + if (slot == null or r.w <= config.GUTTER or r.h <= pardes.BOX_H) continue; + if (@as(u32, r.x) + r.w > surface.cols or @as(u32, r.y) + r.h > surface.rows) continue; + const tag_y = if (core.settings.tag_bottom) r.y + r.h - pardes.BOX_H else r.y; + const body_y = if (core.settings.tag_bottom) r.y else r.y + pardes.BOX_H; + const body_end = body_y + r.h - pardes.BOX_H; + const x0 = @as(f32, @floatFromInt(r.x)) * layout.w; + const x1 = x0 + @as(f32, @floatFromInt(r.w)) * layout.w; + const tag_cell = &surface.cells[@as(usize, tag_y) * surface.cols + r.x]; + const tag_cells = surface.cells[@as(usize, tag_y) * surface.cols + r.x ..][0..r.w]; + const tag_visible = for (tag_cells) |*cell| { + if (cellFontRole(cell) != .tagline) break false; + } else true; + if (tag_visible) { + const rule_y = @as(f32, @floatFromInt(tag_y + @as(u16, if (core.settings.tag_bottom) 0 else 1))) * layout.h; + builder.addRect(x0, rule_y - @as(f32, if (core.settings.tag_bottom) 0 else 1), x1, rule_y + @as(f32, if (core.settings.tag_bottom) 1 else 0), overlayRgb(chrome.border)); + + // A quiet grip gives the normal-mode move box a purpose at rest. + // Insert and terminal badges retain their existing visible glyphs. + if (tag_cell.printableAscii() == ' ' and g.tagline_height >= 6 and g.tagline_width >= 3) { + const tag_layout = taglineLayoutForCell(g, core, r.x, tag_y, null); + const center_x = tag_layout.x_off + (@as(f32, @floatFromInt(r.x)) + @as(f32, @floatFromInt(config.GUTTER)) / 2) * tag_layout.w; + const center_y = @as(f32, @floatFromInt(tag_y)) * layout.h + + @as(f32, @floatFromInt(taglineBandOffset(tag_y, builder.win_h, g.cell_h, g.tagline_height))) + + @as(f32, @floatFromInt(g.tagline_height)) / 2; + const half_w = @max(1, @min(3, tag_layout.w - 2)); + const ink = overlayRgb(switch (tag_cell.style.fg) { + .rgb => |rgb| rgb, + else => chrome.tag_fg, + }); + builder.addRect(@floor(center_x - half_w), @floor(center_y - 2), @floor(center_x + half_w), @floor(center_y - 1), ink); + builder.addRect(@floor(center_x - half_w), @floor(center_y + 1), @floor(center_x + half_w), @floor(center_y + 2), ink); + } + } + + // Do not paint over resize previews or debug text occupying the rail. + var thumb_start: ?u16 = null; + var thumb_end: u16 = body_y; + var row = body_y; + while (row < body_end) : (row += 1) { + const cell = &surface.cells[@as(usize, row) * surface.cols + r.x]; + if (cell.printableAscii() != ' ') break; + if (cellBackgroundIs(cell, chrome.scroll_thumb)) { + if (thumb_start == null) thumb_start = row; + thumb_end = row + 1; + } else if (!cellBackgroundIs(cell, chrome.scroll_track)) break; + } + if (row != body_end) continue; + const y0 = @as(f32, @floatFromInt(body_y)) * layout.h; + const y1 = @as(f32, @floatFromInt(body_end)) * layout.h; + builder.addRect(x0, y0, x0 + layout.w, y1, page); + appendSquareRail(builder, x0, y0, y1, layout.w, false, overlayRgb(chrome.scroll_track)); + if (thumb_start) |first| { + const thumb_y0 = @as(f32, @floatFromInt(first)) * layout.h; + const thumb_y1 = @as(f32, @floatFromInt(thumb_end)) * layout.h; + appendSquareRail(builder, x0, thumb_y0, thumb_y1, layout.w, true, overlayRgb(chrome.scroll_thumb)); + } + } + appendColumnSpines(core, surface.rows, layout, builder); +} + +fn appendSquareRail(builder: *OverlayBuilder, x: f32, y0: f32, y1: f32, cell_w: f32, thumb: bool, color: OverlayColor) void { + const width = @min(cell_w, if (thumb) @max(2, @floor(cell_w * 0.5)) else 2); + builder.addRect(x, y0, x + width, y1, color); +} + +fn appendColumnSpines(core: *const pardes.Pardes, rows: u16, layout: CellLayout, builder: *OverlayBuilder) void { + const y0 = @as(f32, @floatFromInt(pardes.TOPBAR_H)) * layout.h; + const y1 = @as(f32, @floatFromInt(rows)) * layout.h; + if (y1 <= y0) return; + // A single boundary spans all panes, including their tags. Its left edge + // agrees with the square scroll markers and existing move-box hit area. + for (core.col_x[0..core.ncol], core.col_w[0..core.ncol]) |col, width| { + if (width == 0) continue; + const x = @as(f32, @floatFromInt(col)) * layout.w; + builder.addRect(x, y0, x + 1, y1, overlayRgb(core.chromeTheme().border)); + } +} + +test "square scroll markers share the column edge and have no rounded end inset" { + var vertices: [12]OverlayVertex = undefined; + var builder: OverlayBuilder = .{ .vertices = &vertices, .win_w = 800, .win_h = 480 }; + appendSquareRail(&builder, 400, 40, 480, 10, false, overlayRgb(.{ 40, 40, 40 })); + appendSquareRail(&builder, 400, 80, 160, 10, true, overlayRgb(.{ 100, 100, 100 })); + try std.testing.expectEqual(@as(usize, 12), builder.len); + try std.testing.expectEqual(vertices[0].x, vertices[6].x); + try std.testing.expectApproxEqAbs(@as(f32, 1 - 80.0 / 480.0 * 2), vertices[6].y, 0.0001); + try std.testing.expectApproxEqAbs(@as(f32, 1 - 160.0 / 480.0 * 2), vertices[8].y, 0.0001); + try std.testing.expectApproxEqAbs(@as(f32, 5.0 / 800.0 * 2), vertices[7].x - vertices[6].x, 0.0001); +} + +test "column spines remain continuous across every pane tag and body" { + const core = try pardes.Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 }); + defer core.deinit(); + _ = try core.newShell(1, ""); + try std.testing.expect(pardes.layout.splitColumn(core, 0, 1, false)); + pardes.test_api.sync(core); + var vertices: [12]OverlayVertex = undefined; + var builder: OverlayBuilder = .{ .vertices = &vertices, .win_w = 800, .win_h = 480 }; + appendColumnSpines(core, 24, .{ .w = 10, .h = 20, .x_off = 0, .y_off = 0 }, &builder); + try std.testing.expectEqual(@as(usize, 12), builder.len); + for (0..2) |col| { + const first = vertices[col * 6]; + const bottom = vertices[col * 6 + 2]; + try std.testing.expectApproxEqAbs(@as(f32, 1 - 20.0 / 480.0 * 2), first.y, 0.0001); + try std.testing.expectEqual(@as(f32, -1), bottom.y); + try std.testing.expectApproxEqAbs(@as(f32, @floatFromInt(core.col_x[col])) * 10.0 / 800.0 * 2 - 1, first.x, 0.0001); + } +} + fn appendTouchOverlay(g: *Gui, builder: *OverlayBuilder, debug_on: bool) void { if (!debug_on) { g.touch.click_flash_frames = 0; diff --git a/src/gui/pet.zig b/src/gui/pet.zig new file mode 100644 index 00000000..2b031f0e --- /dev/null +++ b/src/gui/pet.zig @@ -0,0 +1,170 @@ +//! Original code-native pixel companions. Pure, bounded geometry and a 10 Hz +//! animation clock: no textures, allocation, input handling or editor state. +const std = @import("std"); + +pub const Kind = enum { off, cat, frog }; +pub const width = 12; +pub const height = 9; +pub const max_vertices = width * height * 6; +pub const Lane = struct { left: f32, right: f32, top: f32, scale: f32 }; +pub const Pose = struct { x: f32, y: f32, scale: f32, left: bool, walking: bool, frame: u8 }; + +/// Only the trailing blank part of the global tag is eligible. The caller +/// supplies its last occupied cell, including any wide-grapheme continuation. +pub fn lane(occupied: usize, cell_w: f32, screen_w: f32, top: f32, band_h: f32, editing: bool) ?Lane { + if (editing or cell_w <= 0 or band_h < height + 2) return null; + const scale = @min(3, @floor((band_h - 2) / height)); + const left = @as(f32, @floatFromInt(occupied + 2)) * cell_w; + const right = screen_w - 2 * cell_w - width * scale; + if (right - left < 4 * scale) return null; + return .{ .left = left, .right = right, .top = top + band_h - height * scale - 1, .scale = scale }; +} + +pub const State = struct { + kind: Kind = .off, + tick: ?u64 = null, + x: f32 = 0, + left: bool = false, + idle: u8 = 12, + frame: u8 = 0, + + pub fn sample(s: *State, kind: Kind, now_ms: u64, area: ?Lane) ?Pose { + const region = area orelse { + s.* = .{}; + return null; + }; + if (kind == .off) { + s.* = .{}; + return null; + } + const tick = now_ms / 100; + if (s.kind != kind or s.tick == null) { + s.* = .{ .kind = kind, .tick = tick, .x = region.left }; + } + s.x = std.math.clamp(s.x, region.left, region.right); + // Coming back from suspension never launches the pet across the tag. + const steps = @min(tick -| s.tick.?, 2); + s.tick = tick; + for (0..steps) |_| { + s.frame +%= 1; + if (s.idle > 0) { + s.idle -= 1; + continue; + } + const speed: f32 = if (kind == .cat) 1.8 else 1.3; + s.x += (if (s.left) -speed else speed) * region.scale; + if (s.x >= region.right or s.x <= region.left) { + s.x = std.math.clamp(s.x, region.left, region.right); + s.left = !s.left; + s.idle = if (kind == .cat) 18 else 24; + } + } + return .{ .x = @floor(s.x), .y = region.top, .scale = region.scale, .left = s.left, .walking = s.idle == 0, .frame = s.frame }; + } +}; + +const cat = [_][]const u8{ + " # # ", + " #####", + " ##o##", + " # ######", + " # ######## ", + " ######### ", + " ######## ", + " ## ## ", + " # # ", +}; +const frog = [_][]const u8{ + " ", + " ## ## ", + " #o####o# ", + " ######## ", + " ###### ", + " ######## ", + " ########## ", + " ## ## ", + "### ###", +}; + +/// 0 transparent, 1 theme ink, 2 eye cutout. Walk legs and idle blinks are +/// generated in this fixed 12x9 tile; neither can spill into an adjacent row. +pub fn pixel(kind: Kind, pose: Pose, x_in: usize, y: usize) u2 { + if (x_in >= width or y >= height or kind == .off) return 0; + const x = if (pose.left) width - 1 - x_in else x_in; + const alternate = (pose.frame / 2) % 2 == 1; + // An idle companion still looks alive during its first pause: a slow + // two-pixel tail-tip flick, or the frog's throat gently inflating. These + // remain inside the tile and mirror with the rest of the animal. + const breathing = (pose.frame / 4) % 2 == 1; + if (!pose.walking and breathing) { + if (kind == .cat and y == 3 and x <= 1) + return @intFromBool(x == 0); + if (kind == .frog and y == 4 and (x == 2 or x == 9)) return 1; + } + if (pose.walking and alternate and y >= 7) { + if (kind == .cat) return @intFromBool((y == 7 and (x == 3 or x == 7)) or (y == 8 and (x == 2 or x == 8))); + return @intFromBool((y == 7 and (x == 2 or x == 9)) or (y == 8 and (x <= 2 or x >= 9))); + } + const ch = (if (kind == .cat) cat else frog)[y][x]; + return switch (ch) { + '#' => 1, + 'o' => if (!pose.walking and pose.frame % 30 >= 27) 1 else 2, + else => 0, + }; +} + +test "pet stays in trailing workspace blanks and disappears during editing" { + try std.testing.expect(lane(20, 8, 200, 0, 16, false) == null); + try std.testing.expect(lane(3, 8, 200, 0, 16, true) == null); + try std.testing.expect(lane(3, 8, 200, 0, 10, false) == null); + const region = lane(3, 8, 200, 2, 20, false).?; + try std.testing.expect(region.left >= 5 * 8); + var state: State = .{}; + var moved = false; + var reversed = false; + for (0..2000) |i| { + const pose = state.sample(.cat, i * 100, region).?; + try std.testing.expect(pose.x >= region.left and pose.x + width * pose.scale <= 200 - 16); + try std.testing.expect(pose.y >= 2 and pose.y + height * pose.scale < 22); + moved = moved or pose.walking; + reversed = reversed or pose.left; + } + try std.testing.expect(moved and reversed); + try std.testing.expect(state.sample(.cat, 300000, null) == null); + try std.testing.expect(state.sample(.off, 300100, region) == null); +} + +test "pet clock is fixed rate and sprites bounded in both directions" { + const region = lane(1, 8, 400, 0, 16, false).?; + var state: State = .{}; + const start = state.sample(.frog, 1000, region).?; + try std.testing.expectEqualDeep(start, state.sample(.frog, 1099, region).?); + for ([_]Kind{ .cat, .frog }) |kind| for ([_]bool{ false, true }) |left| { + const pose: Pose = .{ .x = 0, .y = 0, .scale = 1, .left = left, .walking = true, .frame = 3 }; + var count: usize = 0; + for (0..height) |y| for (0..width) |x| { + count += @intFromBool(pixel(kind, pose, x, y) != 0); + }; + try std.testing.expect(count > 20 and count <= width * height); + try std.testing.expectEqual(@as(u2, 0), pixel(kind, pose, width, height)); + }; +} + +test "pet initial idle visibly animates without walking or leaving its tile" { + const region = lane(1, 8, 400, 0, 16, false).?; + for ([_]Kind{ .cat, .frog }) |kind| { + var state: State = .{}; + const initial = state.sample(kind, 0, region).?; + var later = initial; + for (1..5) |tick| later = state.sample(kind, tick * 100, region).?; + try std.testing.expect(!initial.walking and !later.walking); + try std.testing.expectEqual(initial.x, later.x); + try std.testing.expectEqual(initial.y, later.y); + var changes: usize = 0; + for (0..height) |y| for (0..width) |x| { + changes += @intFromBool(pixel(kind, initial, x, y) != pixel(kind, later, x, y)); + }; + try std.testing.expectEqual(@as(usize, 2), changes); + try std.testing.expectEqual(@as(u2, 0), pixel(kind, later, width, height)); + } +} diff --git a/src/layout.zig b/src/layout.zig index 51944856..6c5f4474 100644 --- a/src/layout.zig +++ b/src/layout.zig @@ -669,7 +669,7 @@ pub fn movePlacement(p: *Pardes, id: usize, cur_x: u16, cur_y: u16) ?MovePlaceme const sib = if (src.idx > 0) p.col_panes[src.col][src.idx - 1] else p.col_panes[src.col][src.idx + 1]; heights[sib] +|= p.rects[id].h; } - var y: u16 = TOPBAR_H; + var y: u16 = TOPBAR_H + p.columnBarHeight(); var last: ?MovePlacement = null; for (0..p.col_n[tc]) |k| { const pid = p.col_panes[tc][k]; @@ -723,7 +723,9 @@ pub fn setColumnWeights(p: *Pardes, col: usize, heights: *const [MAX_PANES]u16) if (col >= p.ncol) return; for (0..p.col_n[col]) |k| { const pid = p.col_panes[col][k]; - if (p.panes[pid]) |pane| pane.vweight = @floatFromInt(@max(1, heights[pid])); + if (p.panes[pid]) |pane| if (!pane.collapsed) { + pane.vweight = @floatFromInt(@max(1, heights[pid])); + }; } } @@ -736,6 +738,17 @@ pub fn applyRowSplit(p: *Pardes, cc: usize, k: usize, cur_y: u16) void { const combined: f32 = @floatFromInt(ra.h + rb.h); var nt: f32 = @floatFromInt(if (p.settings.tag_bottom) cur_y -| ra.y else (cur_y + 1) -| ra.y); nt = std.math.clamp(nt, @as(f32, BOX_H), @max(@as(f32, BOX_H), combined - BOX_H)); + if (combined <= 0) return; + // Dragging a folded tag open is an explicit resize, so switch this pair + // back to visible-height weights without consuming other folded weights. + if ((a.collapsed and nt > BOX_H) or (b.collapsed and combined - nt > BOX_H)) { + snapColWeights(p, cc); + a.vweight = @floatFromInt(@max(1, ra.h)); + b.vweight = @floatFromInt(@max(1, rb.h)); + if (nt > BOX_H) a.collapsed = false; + if (combined - nt > BOX_H) b.collapsed = false; + } + if (a.collapsed or b.collapsed) return; const pair = a.vweight + b.vweight; a.vweight = pair * (nt / combined); b.vweight = pair - a.vweight; @@ -770,8 +783,11 @@ pub fn removePane(p: *Pardes, id: usize) void { p.col_panes[j] = p.col_panes[j + 1]; p.col_n[j] = p.col_n[j + 1]; p.col_weight[j] = p.col_weight[j + 1]; + p.col_tags[j] = p.col_tags[j + 1]; } p.ncol -= 1; + p.col_tags[p.ncol] = .{}; + p.topbar_col = null; } } @@ -788,8 +804,11 @@ pub fn joinCol(p: *Pardes) void { p.col_panes[j] = p.col_panes[j + 1]; p.col_n[j] = p.col_n[j + 1]; p.col_weight[j] = p.col_weight[j + 1]; + p.col_tags[j] = p.col_tags[j + 1]; } p.ncol -= 1; + p.col_tags[p.ncol] = .{}; + p.topbar_col = null; } pub fn canSplitColumn(p: *Pardes, source_id: usize) bool { @@ -833,8 +852,11 @@ pub fn splitColumn(p: *Pardes, source_id: usize, id: usize, before: bool) bool { p.col_panes[j] = p.col_panes[j - 1]; p.col_n[j] = p.col_n[j - 1]; p.col_weight[j] = p.col_weight[j - 1]; + p.col_tags[j] = p.col_tags[j - 1]; } p.col_weight[c] = new_weight; + p.col_tags[c] = .{}; + p.topbar_col = null; p.col_panes[c][0] = id; p.col_n[c] = 1; p.ncol += 1; @@ -844,7 +866,9 @@ pub fn splitColumn(p: *Pardes, source_id: usize, id: usize, before: bool) bool { pub fn snapColWeights(p: *Pardes, c: usize) void { for (0..p.col_n[c]) |k| { const pid = p.col_panes[c][k]; - if (p.panes[pid]) |pp| pp.vweight = @floatFromInt(@max(1, p.rects[pid].h)); + if (p.panes[pid]) |pp| if (!pp.collapsed) { + pp.vweight = @floatFromInt(@max(1, p.rects[pid].h)); + }; } } @@ -886,11 +910,11 @@ pub fn splitBelow(p: *Pardes, src_id: usize, nw: *Pane) void { const keep = std.math.clamp(cur, 1, @max(1, body -| (BOX_H + 3))); if (findPane(p, src_id)) |f| for (0..p.col_n[f.col]) |k| { const pid = p.col_panes[f.col][k]; - if (p.panes[pid]) |pp| if (pp != nw) { + if (p.panes[pid]) |pp| if (pp != nw and !pp.collapsed) { pp.vweight = @floatFromInt(@max(1, p.rects[pid].h)); }; }; - src.vweight = @floatFromInt(BOX_H + keep); + if (!src.collapsed) src.vweight = @floatFromInt(BOX_H + keep); nw.vweight = @floatFromInt(@max(1, src_h -| (BOX_H + keep))); if (nw.file) |f| if (!panes.Output.fileTraits(f.output).doc) { // trimmed: every row ends in a newline, and the empty line after @@ -924,6 +948,81 @@ pub fn columnBoundary(width: u16, prefix: u128, total: u128) u16 { return @intCast(@min(@as(u128, width), pixels)); } +pub fn toggleCollapse(p: *Pardes, id: usize) void { + const pane = p.panes[id] orelse return; + const at = findPane(p, id) orelse return; + compute(p); + const was_collapsed = pane.collapsed; + const old_height = p.rects[id].h; + const remembered_height = pane.vweight; + // Convert only visible panes to row weights. The toggle then changes one + // pair, rather than letting the column redistribute the released space. + snapColWeights(p, at.col); + const neighbor = collapseNeighbor(p, at.col, at.idx); + if (was_collapsed) { + if (neighbor) |other| { + const wanted: u16 = @intFromFloat(@min(@as(f32, @floatFromInt(std.math.maxInt(u16))), @max(1, @round(remembered_height)))); + const give = @min(wanted -| old_height, p.rects[other].h -| BOX_H); + p.panes[other].?.vweight -= @floatFromInt(give); + pane.vweight = @floatFromInt(@max(1, old_height + give)); + } + pane.collapsed = false; + } else { + // A folded pane's weight stores its former height, including its tag, + // so Dump/Restore and a later expansion retain a useful target size. + pane.vweight = @floatFromInt(@max(1, old_height)); + pane.collapsed = true; + if (neighbor) |other| + p.panes[other].?.vweight += @floatFromInt(old_height -| BOX_H); + } + compute(p); +} + +fn collapseNeighbor(p: *Pardes, col: usize, index: usize) ?usize { + var above = index; + while (above > 0) { + above -= 1; + const id = p.col_panes[col][above]; + if (p.panes[id]) |pane| if (!pane.collapsed) return id; + } + for (p.col_panes[col][index + 1 .. p.col_n[col]]) |id| + if (p.panes[id]) |pane| if (!pane.collapsed) return id; + return null; +} + +fn computeCollapsedColumn(p: *Pardes, c: usize, x: u16, width: u16, folded: usize) void { + var y: u16 = TOPBAR_H + p.columnBarHeight(); + const available = p.screen_h -| y; + var expanded_left: usize = 0; + var weight: f32 = 0; + for (p.col_panes[c][0..p.col_n[c]]) |id| if (p.panes[id]) |pane| { + if (!pane.collapsed) { + expanded_left += 1; + weight += pane.vweight; + } + }; + const expanded_height = available -| @as(u16, @intCast(folded)); + var budget = expanded_height; + var folded_left = folded; + for (p.col_panes[c][0..p.col_n[c]]) |id| { + const pane = p.panes[id] orelse continue; + const room = p.screen_h -| y; + const height: u16 = if (pane.collapsed) blk: { + folded_left -= 1; + break :blk @min(room, BOX_H); + } else blk: { + expanded_left -= 1; + const limit = @min(budget, room -| @as(u16, @intCast(folded_left))); + const share = @as(f32, @floatFromInt(expanded_height)) * pane.vweight / (if (weight > 0) weight else 1); + const h = if (expanded_left == 0) limit else @min(limit -| @as(u16, @intCast(expanded_left)), @max(1, @as(u16, @intFromFloat(@round(share))))); + budget -|= h; + break :blk h; + }; + p.rects[id] = .{ .x = x, .y = y, .w = width, .h = height }; + y +|= height; + } +} + pub fn compute(p: *Pardes) void { p.rects = @splat(.{}); if (p.ncol == 0) return; @@ -945,14 +1044,23 @@ pub fn compute(p: *Pardes) void { p.col_x[c] = x; p.col_w[c] = cw; + var folded: usize = 0; + for (p.col_panes[c][0..p.col_n[c]]) |id| if (p.panes[id]) |pane| { + if (pane.collapsed) folded += 1; + }; + if (folded > 0) { + computeCollapsedColumn(p, c, x, cw, folded); + continue; + } + var vsum: f32 = 0; for (0..p.col_n[c]) |k| { if (p.panes[p.col_panes[c][k]]) |pane| vsum += pane.vweight; } if (vsum <= 0) vsum = 1; - var y: u16 = TOPBAR_H; - const avail_h = p.screen_h -| TOPBAR_H; + var y: u16 = TOPBAR_H + p.columnBarHeight(); + const avail_h = p.screen_h -| y; for (0..p.col_n[c]) |k| { const id = p.col_panes[c][k]; const pane = p.panes[id] orelse continue; @@ -968,6 +1076,211 @@ pub fn compute(p: *Pardes) void { pub const ascii_max_movement_frames: u16 = 12; +test "collapse transfers height upward and restores the original layout" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 32 }); + defer p.deinit(); + _ = try p.newShell(1, ""); + _ = try p.newShell(2, ""); + insert(p, 0, 1, 1); + insert(p, 0, 2, 2); + p.panes[0].?.vweight = 3; + p.panes[1].?.vweight = 2; + compute(p); + const original = p.rects; + toggleCollapse(p, 1); + try std.testing.expectEqual(@as(u16, 1), p.rects[1].h); + try std.testing.expectEqual(@as(f32, @floatFromInt(original[1].h)), p.panes[1].?.vweight); + try std.testing.expectEqual(p.screen_h, p.rects[2].y + p.rects[2].h); + try std.testing.expect(p.rects[0].h > original[0].h); + try std.testing.expectEqualDeep(original[2], p.rects[2]); + toggleCollapse(p, 1); + try std.testing.expectEqualDeep(original, p.rects); + toggleCollapse(p, 2); + try std.testing.expectEqual(@as(u16, 1), p.rects[2].h); + try std.testing.expectEqual(p.screen_h, p.rects[2].y + p.rects[2].h); +} + +test "collapse changes only one neighbor at the top middle and bottom" { + for (0..4) |target| { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 58 }); + defer p.deinit(); + for (1..4) |id| { + _ = try p.newShell(id, ""); + insert(p, 0, id, id); + } + for ([_]f32{ 11, 7, 5, 3 }, 0..) |weight, id| p.panes[id].?.vweight = weight; + compute(p); + const original = p.rects; + const recipient = if (target > 0) target - 1 else 1; + for (0..5) |_| { + toggleCollapse(p, target); + try std.testing.expectEqual(@as(u16, BOX_H), p.rects[target].h); + try std.testing.expectEqual(original[recipient].h + original[target].h - BOX_H, p.rects[recipient].h); + for (0..4) |id| if (id != target and id != recipient) + try std.testing.expectEqualDeep(original[id], p.rects[id]); + toggleCollapse(p, target); + try std.testing.expectEqualDeep(original, p.rects); + } + } +} + +test "collapse skips folded neighbors and expansion reclaims only its neighbor" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 62 }); + defer p.deinit(); + for (1..5) |id| { + _ = try p.newShell(id, ""); + insert(p, 0, id, id); + } + compute(p); + toggleCollapse(p, 1); + toggleCollapse(p, 2); + const original = p.rects; + toggleCollapse(p, 3); + try std.testing.expectEqual(original[0].h + original[3].h - BOX_H, p.rects[0].h); + try std.testing.expectEqualDeep(original[4], p.rects[4]); + for (1..4) |id| try std.testing.expectEqual(@as(u16, BOX_H), p.rects[id].h); + toggleCollapse(p, 3); + try std.testing.expectEqualDeep(original, p.rects); + // With no expanded pane above, give the space to the first one below. + toggleCollapse(p, 0); + const with_folded_above = p.rects; + toggleCollapse(p, 3); + try std.testing.expectEqual(with_folded_above[4].h + with_folded_above[3].h - BOX_H, p.rects[4].h); + for (0..3) |id| try std.testing.expectEqualDeep(with_folded_above[id], p.rects[id]); + toggleCollapse(p, 3); + try std.testing.expectEqualDeep(with_folded_above, p.rects); +} + +test "collapse local transfers remain bounded through repeated tiny window resizes" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 58 }); + defer p.deinit(); + for (1..5) |id| { + _ = try p.newShell(id, ""); + insert(p, 0, id, id); + } + for (0..180) |step| { + p.screen_h = @intCast((step * 17) % 81); + p.settings.column_tags = step % 2 == 0; + p.settings.tag_bottom = step % 3 == 0; + compute(p); + toggleCollapse(p, step % 5); + var used: u16 = 0; + for (0..5) |id| { + const pane = p.panes[id].?; + const rect = p.rects[id]; + try std.testing.expect(std.math.isFinite(pane.vweight) and pane.vweight > 0); + try std.testing.expect(rect.h <= p.screen_h -| rect.y); + if (pane.collapsed) try std.testing.expect(rect.h <= BOX_H); + used += rect.h; + } + try std.testing.expect(used <= p.screen_h); + } +} + +test "collapse expansion is limited to space available in one neighbor" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 42 }); + defer p.deinit(); + for (1..4) |id| { + _ = try p.newShell(id, ""); + insert(p, 0, id, id); + } + compute(p); + toggleCollapse(p, 2); + snapColWeights(p, 0); + // An intervening resize gave most of the donor's space to another pane. + p.panes[0].?.vweight += p.panes[1].?.vweight - 2; + p.panes[1].?.vweight = 2; + compute(p); + const before = p.rects; + toggleCollapse(p, 2); + try std.testing.expectEqual(@as(u16, 2), p.rects[2].h); + try std.testing.expectEqual(@as(u16, BOX_H), p.rects[1].h); + try std.testing.expectEqualDeep(before[0], p.rects[0]); + try std.testing.expectEqualDeep(before[3], p.rects[3]); +} + +test "collapse single and all panes leave unused column space" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 32 }); + defer p.deinit(); + toggleCollapse(p, 0); + try std.testing.expectEqual(@as(u16, 1), p.rects[0].h); + _ = try p.newShell(1, ""); + insert(p, 0, 1, 1); + toggleCollapse(p, 1); + try std.testing.expectEqual(@as(u16, 1), p.rects[0].h); + try std.testing.expectEqual(@as(u16, 1), p.rects[1].h); + try std.testing.expectEqual(p.rects[0].y + 1, p.rects[1].y); + try std.testing.expect(p.rects[1].y + 1 < p.screen_h); + toggleCollapse(p, 0); + try std.testing.expectEqual(p.screen_h - 1, p.rects[1].y); +} + +test "collapse survives tiny resizes column tags and TagBottom" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 32 }); + defer p.deinit(); + _ = try p.newShell(1, ""); + _ = try p.newShell(2, ""); + insert(p, 0, 1, 1); + insert(p, 0, 2, 2); + p.panes[1].?.collapsed = true; + for ([_]bool{ false, true }) |bottom| { + p.settings.tag_bottom = bottom; + for ([_]bool{ false, true }) |column_tags| { + p.settings.column_tags = column_tags; + for (0..50) |height| { + p.screen_h = @intCast(height); + compute(p); + var used: u16 = 0; + for (p.col_panes[0][0..p.col_n[0]]) |id| { + const rect = p.rects[id]; + try std.testing.expect(rect.h <= p.screen_h -| rect.y); + used += rect.h; + } + try std.testing.expect(used <= p.screen_h); + if (height >= 5) try std.testing.expectEqual(@as(u16, 1), p.rects[1].h); + } + } + } + p.screen_h = 32; + compute(p); + try std.testing.expectEqual(@as(u16, 1), p.rects[1].h); +} + +test "collapse can be expanded by dragging its boundary" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 32 }); + defer p.deinit(); + _ = try p.newShell(1, ""); + insert(p, 0, 1, 1); + toggleCollapse(p, 0); + applyRowSplit(p, 0, 0, p.rects[0].y + 5); + compute(p); + try std.testing.expect(!p.panes[0].?.collapsed); + try std.testing.expectEqual(@as(u16, 6), p.rects[0].h); + toggleCollapse(p, 1); + p.settings.tag_bottom = true; + applyRowSplit(p, 0, 0, p.rects[1].y - 5); + compute(p); + try std.testing.expect(!p.panes[1].?.collapsed); + try std.testing.expectEqual(@as(u16, 6), p.rects[1].h); +} + +test "collapse dump restores folded state and retained expansion weight" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 32 }); + defer p.deinit(); + p.panes[0].?.vweight = 7; + compute(p); + const original_height = p.rects[0].h; + toggleCollapse(p, 0); + try p.dumpState(); + const restored = try Pardes.initFromDump(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 32 }, p.dump_out.?); + defer restored.deinit(); + try std.testing.expect(restored.panes[0].?.collapsed); + try std.testing.expectEqual(@as(f32, @floatFromInt(original_height)), restored.panes[0].?.vweight); + try std.testing.expectEqual(@as(u16, 1), restored.rects[0].h); + toggleCollapse(restored, 0); + try std.testing.expectEqual(restored.screen_h - restored.rects[0].y, restored.rects[0].h); +} + pub const Easing = enum(u8) { linear, smooth, diff --git a/src/macos.zig b/src/macos.zig index 3e4edd12..96598b90 100644 --- a/src/macos.zig +++ b/src/macos.zig @@ -1846,10 +1846,11 @@ export fn pardes_take_haptic() c_int { var font_path_z: [4096:0]u8 = undefined; -export fn pardes_font_take() ?[*:0]const u8 { +export fn pardes_font_take(size_hundredths: *u16) ?[*:0]const u8 { const st = &(state orelse return null); if (comptime !pardes.font_picker) return null; const want = st.core.takeFontRequest() orelse return null; + size_hundredths.* = st.core.settings.font.requested_size_hundredths; if (want.len >= font_path_z.len) return null; @memcpy(font_path_z[0..want.len], want); font_path_z[want.len] = 0; diff --git a/src/macos/Sources/AppDelegate.swift b/src/macos/Sources/AppDelegate.swift index 2d411873..eb499f1b 100644 --- a/src/macos/Sources/AppDelegate.swift +++ b/src/macos/Sources/AppDelegate.swift @@ -585,8 +585,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, PardesViewDelegate { // is already waiting by the time the tick returns. The view re-measures // and calls back through pardesViewDidResize, so the grid the core is // holding follows the cell that just changed size. - if let wanted = pardes_font_take() { - view.adoptFont(path: String(cString: wanted)) + var fontSize: UInt16 = 0 + if let wanted = pardes_font_take(&fontSize) { + view.adoptFont(path: String(cString: wanted), sizeHundredths: fontSize) } if pardes_should_quit() { diff --git a/src/macos/Sources/PardesView.swift b/src/macos/Sources/PardesView.swift index f9afa7b1..5c5bc8ba 100644 --- a/src/macos/Sources/PardesView.swift +++ b/src/macos/Sources/PardesView.swift @@ -761,8 +761,9 @@ final class PardesView: NSView { } /// The file `Font ` resolved to, straight from the core. - func adoptFont(path: String) { - wear(size: fontSize, path: path, resolvesRequest: true) + func adoptFont(path: String, sizeHundredths: UInt16) { + let size = sizeHundredths == 0 ? fontSize : CGFloat(sizeHundredths) / 100 + wear(size: size, path: path, resolvesRequest: true) } /// Cmd+ and Cmd-. Whole points, because the cell is rounded to whole diff --git a/src/macos/pardes.h b/src/macos/pardes.h index c28b3514..845c8096 100644 --- a/src/macos/pardes.h +++ b/src/macos/pardes.h @@ -486,7 +486,8 @@ pardes_haptic_e pardes_take_haptic(void); // pardes_resize. A file the host cannot load must be rejected explicitly while // keeping the face that works; otherwise Config would leave the request pending // forever or claim a fallback was accepted. -const char *pardes_font_take(void); +// Optional size in native points; zero preserves the current size. +const char *pardes_font_take(uint16_t *size_hundredths); // Boot, zoom and display-scale changes observe the face already on screen; // this never consumes a pending request. `point_hundredths` is the effective diff --git a/src/panes.zig b/src/panes.zig index 6db7f53b..6a5a2739 100644 --- a/src/panes.zig +++ b/src/panes.zig @@ -83,6 +83,8 @@ pub const Pane = struct { serial: u32 = 0, mode: Mode = .normal, vweight: f32 = 1, + /// A folded pane keeps its expansion weight, but occupies only its tag row. + collapsed: bool = false, cols: u16, rows: u16, greet: bool = false, @@ -125,6 +127,12 @@ pub const Pane = struct { tag_mode: Mode = .normal, tag_col: u16 = 0, tag_anchor: u16 = 0, + /// Filename edits are staged separately from the live buffer identity. + /// Enter commits the name; leaving tag editing discards the draft. + tag_name: [limits.host_path_cap]u8 = undefined, + tag_name_len: ?u16 = null, + /// Display columns hidden to reveal the tag caret; tag addresses stay whole. + tag_scroll: u16 = 0, ed_undo: [Terminal.history_max]Terminal.Snapshot = undefined, ed_undo_len: usize = 0, ed_redo: [Terminal.history_max]Terminal.Snapshot = undefined, @@ -965,6 +973,7 @@ pub const File = struct { .cols = pane.cols, .rows = pane.rows, .vweight = pane.vweight, + .collapsed = pane.collapsed, .file = .{ .path = file.path, .content = file.content, @@ -1728,7 +1737,6 @@ pub const File = struct { pub fn drawGutter(p: *Pardes, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, body_h: u16, active: bool) void { const s = &p.surface; const prefix_width = gutterWidth(pane); - const ch = p.chromeTheme(); const goff = pane.scroll(); const gcur = Terminal.gridCursor(pane); const gcrow = if (pane.cur_pinned) pane.cur_row else @as(i32, gcur.y) + goff; @@ -1745,10 +1753,11 @@ pub const File = struct { while (c < prefix_width and c < tw) : (c += 1) { const cell = s.at(tx + c, body_y + vr); cell.default = false; // paints blank gutter rows too - if (on_cursor) { - cell.style.fg = .{ .rgb = ch.tag_fg }; - cell.style.bg = .{ .rgb = ch.tag_bg }; - } else cell.style.fg = .{ .rgb = ch.lineno }; + cell.style.fg = .{ .rgb = if (on_cursor) + p.theme().lineno_active orelse p.theme().lineno + else + p.theme().lineno }; + cell.style.bold = on_cursor; } } } @@ -1758,10 +1767,10 @@ pub const File = struct { fn synStyle(p: *Pardes, sy: syntax.Syn) ?SynStyle { return switch (sy) { .none => null, - .keyword => .{ .fg = p.theme().kw, .bold = true }, + .keyword => .{ .fg = p.theme().kw, .bold = p.settings.syntax_bold }, .string => .{ .fg = p.theme().str, .bold = false }, .number => .{ .fg = p.theme().num, .bold = false }, - .comment => .{ .fg = p.theme().comment, .bold = true }, + .comment => .{ .fg = p.theme().comment, .bold = false }, }; } @@ -1770,7 +1779,7 @@ pub const File = struct { pub fn recolorSyntax(p: *Pardes, pane: *Pane, f: *State, r: pardes.Rect, tx: u16, tw: u16, body_h: u16) void { const highlights = if (f.mini) |mini| mini.colors else f.highlights; const highlight_start = if (f.mini != null) 0 else f.highlight_start; - if (highlights.len == 0) return; + if (highlights.len == 0 and f.output == null) return; const s = &p.surface; const prefix_width = gutterWidth(pane); const tz_recolor = tracy.zone(@src(), "synRecolor"); @@ -1803,6 +1812,7 @@ pub const File = struct { line.len; } hs = modal.graphemeStart(line, @min(hs, line.len)); + const decoration = Output.rowDecoration(f.output, line); var c: usize = 0; var screen_c: usize = 0; while (hs + c < limit and prefix_width + screen_c < tw) { @@ -1823,6 +1833,18 @@ pub const File = struct { } } } + // Results keep their exact live text. Only the location, + // matched source range and diagnostic label gain emphasis. + if (decoration.styleAt(p, hs + c)) |style| { + var fill: usize = 0; + while (fill < cells and prefix_width + screen_c + fill < tw) : (fill += 1) { + const cell = s.at(tx + @as(u16, @intCast(prefix_width + screen_c + fill)), body_y + vr); + if (cell.default) continue; + cell.style.fg = style.fg; + cell.style.bold = style.bold; + if (style.bg != .default) cell.style.bg = style.bg; + } + } screen_c += cells; c = grapheme_end - hs; } @@ -1910,6 +1932,80 @@ pub const Output = struct { } }; + const RowDecoration = struct { + location_end: usize = 0, + mark_start: usize = 0, + mark_end: usize = 0, + severity: enum { none, err, warning, info, hint } = .none, + + fn styleAt(row: RowDecoration, p: *const Pardes, byte: usize) ?pardes.CellStyle { + // Body paper changes immediately, so its ink must change with it + // even while the surrounding tags are still fading. + const chrome = pardes.ChromeTheme.fromTheme(p.theme()); + if (byte < row.location_end) return .{ .fg = .{ .rgb = chrome.lineno } }; + if (byte < row.mark_start or byte >= row.mark_end) return null; + if (row.severity == .none) return .{ + .fg = .{ .rgb = chrome.search_fg }, + .bg = .{ .rgb = chrome.search_bg }, + }; + return .{ .fg = .{ .rgb = switch (row.severity) { + .err => chrome.diagnostic_error, + .warning => chrome.diagnostic_warning, + .info => chrome.diagnostic_info, + .hint => chrome.diagnostic_hint, + .none => unreachable, + } }, .bold = true }; + } + }; + + fn rowDecoration(output: ?State, line: []const u8) RowDecoration { + const origin = (output orelse return .{}).from; + const token_end = std.mem.indexOfAny(u8, line, " \t") orelse line.len; + const target = look.parsePathLine(line[0..token_end]); + if (target.at.line == 0 or target.end != token_end) return .{}; + var row: RowDecoration = .{ .location_end = token_end }; + if (token_end == line.len) return row; + const source_start = token_end + 1; + const is_search = switch (origin) { + .search => true, + .cmd => |cmd| cmd == .Grep, + else => false, + }; + if (is_search and target.at.col > 0 and target.at.end_line == target.at.line and + target.at.end_col >= target.at.col) + { + row.mark_start = source_start +| (target.at.col - 1); + row.mark_end = @min(line.len, source_start +| target.at.end_col); + } + const is_diagnostic = switch (origin) { + .query => |kind| kind == .diagnostics or kind == .workspace_diagnostics, + else => false, + }; + if (is_diagnostic) { + const text = line[source_start..]; + inline for (.{ .{ "error:", .err }, .{ "warning:", .warning }, .{ "info:", .info }, .{ "hint:", .hint } }) |label| { + if (std.mem.startsWith(u8, text, label[0])) { + row.mark_start = source_start; + row.mark_end = source_start + label[0].len; + row.severity = label[1]; + } + } + } + return row; + } + + test "result decoration follows source byte ranges and diagnostic provenance" { + const row = rowDecoration(.{ .from = .search }, "src/a.rs:3:5-7 let abc = 1;"); + try std.testing.expectEqualStrings("abc", "src/a.rs:3:5-7 let abc = 1;"[row.mark_start..row.mark_end]); + const prose = rowDecoration(.{ .from = .{ .query = .hover } }, "src/a.rs:3:5-7 error: prose"); + try std.testing.expectEqual(@as(usize, 0), prose.mark_end); + const diagnostic = rowDecoration(.{ .from = .{ .query = .diagnostics } }, "src/a.rs:3:5-7 warning: unused"); + try std.testing.expectEqual(.warning, diagnostic.severity); + try std.testing.expectEqualStrings("warning:", "src/a.rs:3:5-7 warning: unused"[diagnostic.mark_start..diagnostic.mark_end]); + const malformed = rowDecoration(.{ .from = .search }, "src/a.rs:3:99999999999999999999-99999999999999999999 text"); + try std.testing.expect(malformed.mark_end <= "src/a.rs:3:99999999999999999999-99999999999999999999 text".len); + } + pub fn setArg(o: *State, text: []const u8) error{ArgumentTooLong}!void { if (text.len > max_arg) return error.ArgumentTooLong; o.arg_len = @intCast(text.len); @@ -2215,12 +2311,23 @@ pub const Output = struct { pub fn openThemes(p: *Pardes, id: usize) !void { var out: std.Io.Writer.Allocating = .init(p.gpa); errdefer out.deinit(); - for (pardes.themes) |t| + try out.writer.writeAll("# Pardes themes\n"); + for (pardes.themes, 0..) |t, i| { + if (i == pardes.native_theme_count) + try out.writer.writeAll("\n# Legacy and imported themes\n"); try out.writer.print(comptime config.Runtime.findAction(.theme).?.word ++ " {s}\n", .{t.name}); + } const content = try out.toOwnedSlice(); try openStepped(p, id, .{ .cmd = .ThemeSel }, content); } + pub fn isWalkRow(out: ?State, line: []const u8) bool { + const state = out orelse return true; + if (!std.meta.eql(state.from, Origin{ .cmd = .ThemeSel })) return true; + // Section labels are for reading, not stops in the command walk. + return std.mem.startsWith(u8, std.mem.trimStart(u8, line, " \t"), comptime config.Runtime.findAction(.theme).?.word ++ " "); + } + pub fn openFonts(p: *Pardes, id: usize) !void { if (builtins.capabilities.font_picker) { const arena = p.scratch.allocator(); @@ -2827,6 +2934,7 @@ pub const Image = struct { .cols = pane.cols, .rows = pane.rows, .vweight = pane.vweight, + .collapsed = pane.collapsed, .image = .{ .path = path, .bytes_b64 = if (bytes.len > 0) try dump.encodeBytes(arena, bytes) else "", @@ -3026,6 +3134,15 @@ pub const Pdf = struct { }; pub fn legacySavedPrefix(path: []const u8, saved_tag: []const u8) ?[]const u8 { + if (std.mem.startsWith(u8, saved_tag, path) and std.mem.startsWith(u8, saved_tag[path.len..], " [")) { + const start = path.len + 2; + const end = std.mem.indexOfScalarPos(u8, saved_tag, start, ']') orelse return null; + var parts = std.mem.splitScalar(u8, saved_tag[start..end], '/'); + _ = std.fmt.parseInt(usize, parts.next() orelse return null, 10) catch return null; + _ = std.fmt.parseInt(usize, parts.next() orelse return null, 10) catch return null; + if (parts.next() != null) return null; + return saved_tag[0 .. end + 1]; + } if (!std.mem.startsWith(u8, saved_tag, "pdf ")) return null; const marker = " PdfSections "; const marker_at = std.mem.indexOf(u8, saved_tag, marker) orelse return null; @@ -6513,6 +6630,7 @@ pub const Terminal = struct { .cols = pane.cols, .rows = pane.rows, .vweight = pane.vweight, + .collapsed = pane.collapsed, .terminal = .{ .cwd = try arena.dupe(u8, pane.cwdSlice()), .stream = try arena.dupe(u8, text), @@ -6561,6 +6679,7 @@ pub const Terminal = struct { .cols = pane.cols, .rows = pane.rows, .vweight = pane.vweight, + .collapsed = pane.collapsed, .terminal = .{ .cwd = try arena.dupe(u8, pane.cwdSlice()), .stream = stream[0..len], @@ -7193,7 +7312,8 @@ pub const Terminal = struct { 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 }); + // Raw index assertions require a theme that inherits the ANSI palette. + const p = try Pardes.init(testing.allocator, .{ .startup_config = "Theme helix\n", .tty_only = true, .cols = 18, .rows = 6 }); defer p.deinit(); while (p.nextEffect()) |_| {} const pane = p.panes[0].?; @@ -7332,10 +7452,8 @@ pub const Terminal = struct { } test "terminal Filter keeps extended keys dark-to-light on a light theme" { - const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + const p = try Pardes.init(std.testing.allocator, .{ .startup_config = "Theme acme\n", .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()); @@ -7385,7 +7503,9 @@ pub const Terminal = struct { const pane = p.panes[0].?; try testing.expect(pane.tty_filter); - for (0..3) |t| { + // Native dark/light palettes plus legacy helix/dark/acme, including + // the null-page fallback that only the legacy dark theme exercises. + for (0..9) |t| { p.settings.theme = @intCast(t); const stage_one = FilteredColors.init(p, pane); // `dark` declares no background of its own, which is exactly why the @@ -7424,7 +7544,7 @@ pub const Terminal = struct { const tx = r.x + config.GUTTER; const body_y = if (p.settings.tag_bottom) r.y else r.y + pardes.BOX_H; - for (0..3) |t| { + for (0..9) |t| { p.settings.theme = @intCast(t); var fc = FilteredColors.init(p, pane); const page = asPardesColor(fc.default_bg); @@ -7476,7 +7596,7 @@ pub const Terminal = struct { while (p.nextEffect()) |_| {} const pane = p.panes[0].?; - for (0..3) |t| { + for (0..9) |t| { p.settings.theme = @intCast(t); var fc = FilteredColors.init(p, pane); var refused: usize = 0; @@ -7515,8 +7635,8 @@ pub const Terminal = struct { 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 red: pardes.Color = .{ .index = 1 }; - const blue: pardes.Color = .{ .index = 4 }; + const red: pardes.Color = .{ .rgb = p.theme().palette.?[1] }; + const blue: pardes.Color = .{ .rgb = p.theme().palette.?[4] }; // tty mode projects the emulator's ansi colours cell for cell. pane.mode = .tty; @@ -7554,7 +7674,7 @@ pub const Terminal = struct { const s = try p.render(frame.allocator()); try testing.expectEqualStrings(" ", s.at(tx, body_y).grapheme()); - try testing.expect(!std.meta.eql(pardes.Color{ .index = 1 }, s.at(tx, body_y).style.bg)); + try testing.expect(!std.meta.eql(pardes.Color{ .rgb = p.theme().palette.?[1] }, s.at(tx, body_y).style.bg)); } pub fn palColor(p: *Pardes, idx: u8) pardes.Color { diff --git a/src/pardes.zig b/src/pardes.zig index 8d5f0ba7..35080163 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -74,6 +74,8 @@ const column_weight_unit = layout.column_weight_unit; const max_column_weight = layout.max_column_weight; pub const MAX_JUMPS = 64; pub const TOPBAR_H: u16 = 1; +pub const COLBAR_H: u16 = 1; +pub const TagLine = @import("tag_line.zig").Line; pub const BOX_H: u16 = 1; pub fn taglineBandOffset(row: u16, canvas_h: f32, cell_h: u32, tagline_h: u32) u32 { @@ -93,6 +95,10 @@ pub fn topbarPaneBorderPixels(cell_h: u32, tagline_h: u32) u32 { pub fn taglineOriginCol(p: *const Pardes, col: u16, row: u16, track: ?layout.Track) f32 { if (row < TOPBAR_H) return 0; + if (row < TOPBAR_H + p.columnBarHeight()) { + for (p.col_x[0..p.ncol], p.col_w[0..p.ncol]) |x, w| + if (col >= x and col - x < w) return @floatFromInt(x); + } if (track) |active| { const box = active.contentBox(); const tag_y = if (p.settings.tag_bottom) box.y + box.h - @as(f32, @floatFromInt(BOX_H)) else box.y; @@ -149,6 +155,18 @@ pub fn gridColAt(p: ?*const Pardes, x: f32, row: u16, body_w: f32, tagline_w: f3 const body = @max(body_w, 1); const tag = @max(tagline_w, 1); if (row < TOPBAR_H) return colFromSpan(x, tag); + if (p) |core| if (row < TOPBAR_H + core.columnBarHeight()) { + for (core.col_x[0..core.ncol], core.col_w[0..core.ncol]) |origin, width| { + if (width == 0) continue; + const left = @as(f32, @floatFromInt(origin)) * body; + const right = @as(f32, @floatFromInt(origin + width)) * body; + if (x < left or x >= right) continue; + return origin + @as(u16, @intFromFloat(@min( + @floor(@max(0, x - left) / tag), + @as(f32, @floatFromInt(width - 1)), + ))); + } + }; if (p) |core| for (core.panes, 0..) |slot, id| { if (slot == null) continue; const r = core.rects[id]; @@ -195,6 +213,13 @@ test "a compact tagline anchors at its own pane, and both shells step from the s const body_w: f32 = 10; const tag_w: f32 = 8; + for (p.col_x[0..p.ncol], p.col_w[0..p.ncol]) |origin, width| { + if (width == 0) continue; + const col = origin; + try std.testing.expectEqual(@as(f32, @floatFromInt(origin)), taglineOriginCol(p, col, TOPBAR_H, null)); + const glyph_x = @as(f32, @floatFromInt(origin)) * body_w; + try std.testing.expectEqual(col, gridColAt(p, glyph_x + tag_w / 2, TOPBAR_H, body_w, tag_w)); + } for ([_]u16{ 0, 1, 5, 40, 119 }) |col| { const origin = taglineOriginCol(p, col, left.y, null); const sdl = origin * (body_w - tag_w) + @as(f32, @floatFromInt(col)) * tag_w; @@ -230,6 +255,7 @@ pub const Loc = struct { test {} const pane_tail = " " ++ config.pane_builtins_str; +const pdf_pane_tail = " " ++ config.pdf_pane_builtins_str; const file_pane_tail = " " ++ config.file_pane_builtins_str; const terminal_pane_tail = " " ++ config.terminal_pane_builtins_str; const dirty_marker = " *"; @@ -243,6 +269,15 @@ const legacy_terminal_pane_tail = " New Del Filter"; // ...and the terminal default from the release before Save reached it, when a // scrollback was not yet something you could write to a path. const prev_terminal_pane_tail = " New Newtty Del Filter"; +const pre_tty_terminal_pane_tail = " Save New Newtty Del Filter"; +const pre_column_pane_tail = " New Newtty Del"; +const pre_column_file_pane_tail = " Save New Newtty Del"; +const pre_column_pdf_pane_tail = " New Newtty Del PdfSections PdfTint"; +const pre_column_terminal_pane_tail = " Save New Newtty Del Tty Filter"; +const pre_collapse_pane_tail = " Tty Del"; +const pre_collapse_file_pane_tail = " Save Tty Del"; +const pre_collapse_pdf_pane_tail = " Tty Del PdfSections PdfTint"; +const pre_collapse_terminal_pane_tail = " Save Tty Del Togglettymode Filter"; const Builtin = builtins.registry.Builtin(); @@ -929,14 +964,14 @@ test "topbar hover highlights exactly the executable word" { p.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = 1, .row = 0 } }); _ = frame.reset(.retain_capacity); const hovered = try p.render(frame.allocator()); - for (0.."New".len) |col| { + for (0.."Newcol".len) |col| { const cell = hovered.at(@intCast(col), 0); try std.testing.expectEqual(Color{ .rgb = p.theme().sel_bg }, cell.style.bg); try std.testing.expectEqual(Color{ .rgb = p.theme().sel_fg }, cell.style.fg); } - try std.testing.expectEqual(Color{ .rgb = p.chromeTheme().tag_bg }, hovered.at(3, 0).style.bg); + try std.testing.expectEqual(Color{ .rgb = p.chromeTheme().tag_bg }, hovered.at(6, 0).style.bg); - p.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = 3, .row = 0 } }); + p.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = 6, .row = 0 } }); _ = frame.reset(.retain_capacity); const whitespace = try p.render(frame.allocator()); try std.testing.expectEqual(Color{ .rgb = p.chromeTheme().tag_bg }, whitespace.at(1, 0).style.bg); @@ -947,7 +982,7 @@ test "topbar hover highlights exactly the executable word" { try std.testing.expectEqual(Color{ .rgb = p.chromeTheme().tag_bg }, left.at(1, 0).style.bg); } -test "pane-tag Exec prefers New and argument builtins before shell fallback" { +test "pane-tag Exec prefers Tty and argument builtins before shell fallback" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .cols = 100, .rows = 30 }); defer p.deinit(); @@ -955,12 +990,12 @@ 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(" Save New Newtty Del Filter", Pardes.curTail(pane)); + try std.testing.expectEqualStrings(" Save Tty Del Togglettymode Filter Collapse", Pardes.curTail(pane)); const tag_x = p.rects[0].x + config.GUTTER; const tag_y = p.rects[0].y; const rendered = try p.tagText(p.scratch.allocator(), pane); - const new_x = tag_x + @as(u16, @intCast(std.mem.indexOf(u8, rendered, "New").?)) + 1; + const new_x = tag_x + @as(u16, @intCast(std.mem.indexOf(u8, rendered, "Tty").?)) + 1; var panes_before: usize = 0; for (p.panes) |s| if (s != null) { panes_before += 1; @@ -976,7 +1011,7 @@ test "pane-tag Exec prefers New and argument builtins before shell fallback" { panes_after += 1; }; try std.testing.expectEqual(panes_before + 1, panes_after); - try std.testing.expect(p.panes[p.active].?.file.?.output != null); // the scratch + try std.testing.expect(p.panes[p.active].?.isTerminal()); // Argument-taking builtins use the same gesture path: a tag sweep is the // complete command line, and Theme consumes its tail without a PTY write. @@ -1018,6 +1053,74 @@ test "pane-tag Exec prefers New and argument builtins before shell fallback" { try std.testing.expectEqualStrings("DefinitelyNotABuiltin\r", sent[0..sent_len]); } +test "Collapse builtin keeps content and restores the pane from its default tag" { + const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 24, .tty_only = true }); + defer p.deinit(); + const pane = try p.setTestFile("keep this text\n"); + p.sync(); + const height = p.rects[0].h; + const revision = pane.file.?.revision; + for ([_][]const u8{ pane_tail, file_pane_tail, pdf_pane_tail, terminal_pane_tail }) |tail| + try std.testing.expect(std.mem.endsWith(u8, tail, " Collapse")); + + try std.testing.expect(p.executeBuiltinLine(0, "Collapse")); + p.sync(); + try std.testing.expect(pane.collapsed); + try std.testing.expectEqual(@as(u16, BOX_H), p.rects[0].h); + try std.testing.expectEqual(@as(f32, @floatFromInt(height)), pane.vweight); + try std.testing.expectEqual(revision, pane.file.?.revision); + try std.testing.expectEqualStrings("keep this text\n", pane.file.?.content); + try std.testing.expect((try p.render(p.scratch.allocator())).cursor == null); + p.enterTagEdit(pane, -1); + const tag_cursor = (try p.render(p.scratch.allocator())).cursor orelse return error.MissingTagCursor; + try std.testing.expectEqual(p.rects[0].y, tag_cursor.y); + Pardes.exitTagEdit(pane); + try std.testing.expect(p.executeBuiltinLine(0, "Collapse")); + p.sync(); + try std.testing.expect(!pane.collapsed); + try std.testing.expectEqual(height, p.rects[0].h); + + // Unedited defaults from the preceding version gain the new command. + pane.tag_init = false; + pane.tag_tail_len = 0; + p.restoreTailAt(pane, pre_collapse_file_pane_tail, "", .file); + try std.testing.expect(!pane.tag_init); + try std.testing.expectEqualStrings(file_pane_tail, Pardes.curTail(pane)); +} + +test "Togglettymode builtin and keyboard chord share mode transitions including edited tags" { + const p = try Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 20, .tty_only = true }); + defer p.deinit(); + const pane = p.panes[0].?; + try std.testing.expect(std.mem.indexOf(u8, Pardes.curTail(pane), " Togglettymode ") != null); + try std.testing.expectEqual(Pane.Mode.tty, pane.mode); + try std.testing.expect(p.executeBuiltinLine(0, "Togglettymode")); + try std.testing.expectEqual(Pane.Mode.normal, pane.mode); + p.update(.{ .key = .{ .cp = p.opts.tty_toggle, .ctrl = true } }); + try std.testing.expectEqual(Pane.Mode.tty, pane.mode); + p.enterTagEdit(pane, -1); + try std.testing.expectEqual(Pane.Mode.insert, pane.mode); + try std.testing.expect(p.executeBuiltinLine(0, "Togglettymode")); + try std.testing.expectEqual(Pane.Mode.normal, pane.mode); + try std.testing.expect(!pane.tag_edit); + p.enterTagEdit(pane, -1); + p.update(.{ .key = .{ .cp = p.opts.tty_toggle, .ctrl = true } }); + try std.testing.expectEqual(Pane.Mode.tty, pane.mode); + try std.testing.expect(!pane.tag_edit); + + p.acknowledgePanelPresentation(&.{}); + const tag = try p.tagText(p.scratch.allocator(), pane); + const x = p.rects[0].x + config.GUTTER + @as(u16, @intCast(std.mem.indexOf(u8, tag, "Togglettymode").?)); + const y = p.rects[0].y; + p.update(.{ .mouse = .{ .button = .middle, .kind = .press, .col = x, .row = y } }); + p.update(.{ .mouse = .{ .button = .middle, .kind = .release, .col = x, .row = y } }); + try std.testing.expectEqual(Pane.Mode.normal, pane.mode); + const file = try p.setTestFile("unchanged\n"); + try std.testing.expect(p.executeBuiltinLine(0, "Togglettymode")); + try std.testing.expectEqual(Pane.Mode.normal, file.mode); + try std.testing.expectEqualStrings("unchanged\n", file.file.?.content); +} + 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; @@ -1054,14 +1157,13 @@ test "Filter is ordered after Del and toggles only its terminal pane" { try testing.expect(!second.tty_filter); } -test "an untouched tagline ends where its layout column's widest one does" { +test "compact tag commands stay beside their own prefix across layout changes" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .cols = 100, .rows = 30 }); defer p.deinit(); while (p.nextEffect()) |_| {} // initial shell spawn/geometry - // a second shell BELOW the first — what Alt-n does — sitting in a - // directory too long to leave tag_right_pad columns free + // A longer sibling never introduces invisible padding into this tag. const below_id = p.freeSlot().?; const below = try p.newShell(below_id, ""); const f = layout.findPane(p, p.active).?; @@ -1073,34 +1175,32 @@ test "an untouched tagline ends where its layout column's widest one does" { const above = p.panes[0].?; try std.testing.expectEqual(p.rects[0].x, p.rects[below_id].x); - const above_tag = try p.tagText(p.scratch.allocator(), above); + const above_tag = try p.tagText(gpa, above); + defer gpa.free(above_tag); const below_tag = try p.tagText(p.scratch.allocator(), below); - try std.testing.expectEqual(below_tag.len, above_tag.len); - const tail = " Save New Newtty Del Filter"; + try std.testing.expect(below_tag.len > above_tag.len); + const tail = " Save Tty Del Togglettymode Filter Collapse"; try std.testing.expectEqualStrings(tail, above_tag[above_tag.len - tail.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); + try std.testing.expectEqual(above.cwdSlice().len + tail.len, above_tag.len); p.update(.{ .resize = .{ .cols = 88, .rows = 30 } }); while (p.nextEffect()) |_| {} - const fits = (try p.tagText(p.scratch.allocator(), above)).len; + try std.testing.expectEqualStrings(above_tag, try p.tagText(p.scratch.allocator(), above)); p.update(.{ .resize = .{ .cols = 87, .rows = 30 } }); while (p.nextEffect()) |_| {} - try std.testing.expectEqual(fits - 1, (try p.tagText(p.scratch.allocator(), above)).len); + try std.testing.expectEqualStrings(above_tag, try p.tagText(p.scratch.allocator(), above)); p.update(.{ .resize = .{ .cols = 100, .rows = 30 } }); while (p.nextEffect()) |_| {} p.seedTail(below); + try std.testing.expectEqualStrings(tail, below.tagSlice()); try std.testing.expect(below.appendTag(" lots and lots of typing out here")); try std.testing.expectEqual(above_tag.len, (try p.tagText(p.scratch.allocator(), above)).len); p.update(.{ .resize = .{ .cols = 100, .rows = 2 } }); while (p.nextEffect()) |_| {} try std.testing.expectEqual(@as(u16, 0), p.rects[below_id].h); - try std.testing.expectEqual( - @as(usize, p.rects[0].w) - config.GUTTER - config.tag_right_pad, - (try p.tagText(p.scratch.allocator(), above)).len, - ); + try std.testing.expectEqualStrings(above_tag, try p.tagText(p.scratch.allocator(), above)); } test "legacy default tag tails upgrade while custom tails remain owned" { @@ -1115,14 +1215,14 @@ 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(" Save New Newtty Del Filter", Pardes.curTail(terminal)); + try std.testing.expectEqualStrings(" Save Tty Del Togglettymode Filter Collapse", Pardes.curTail(terminal)); 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(" Save New Newtty Del Filter", Pardes.curTail(terminal)); + try std.testing.expectEqualStrings(" Save Tty Del Togglettymode Filter Collapse", Pardes.curTail(terminal)); const terminal_prefilter = try std.fmt.allocPrint(p.scratch.allocator(), "{s}{s}", .{ try p.tagPrefix(terminal), @@ -1130,7 +1230,7 @@ test "legacy default tag tails upgrade while custom tails remain owned" { }); p.restoreTail(terminal, terminal_prefilter); try std.testing.expect(!terminal.tag_init); - try std.testing.expectEqualStrings(" Save New Newtty Del Filter", Pardes.curTail(terminal)); + try std.testing.expectEqualStrings(" Save Tty Del Togglettymode Filter Collapse", Pardes.curTail(terminal)); const terminal_custom = try std.fmt.allocPrint(p.scratch.allocator(), "{s} Keep Del", .{ try p.tagPrefix(terminal), @@ -1159,7 +1259,7 @@ test "legacy default tag tails upgrade while custom tails remain owned" { .terminal = .{ .cwd = "/historical/cwd" }, }); try std.testing.expect(!terminal.tag_init); - try std.testing.expectEqualStrings(" Save New Newtty Del Filter", Pardes.curTail(terminal)); + try std.testing.expectEqualStrings(" Save Tty Del Togglettymode Filter Collapse", 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. @@ -1181,7 +1281,7 @@ test "legacy default tag tails upgrade while custom tails remain owned" { }); p.restoreTail(file, file_old); try std.testing.expect(!file.tag_init); - try std.testing.expectEqualStrings(" Save New Newtty Del", Pardes.curTail(file)); + try std.testing.expectEqualStrings(" Save Tty Del Collapse", Pardes.curTail(file)); const file_custom = try std.fmt.allocPrint(p.scratch.allocator(), "{s} Save Mine Del", .{ try p.tagPrefix(file), @@ -1202,7 +1302,7 @@ test "legacy default tag tails upgrade while custom tails remain owned" { try std.testing.expectEqualStrings(" New Del", Pardes.curTail(file)); const image_doc = try panes.Image.create(p, 1, "/tmp/legacy image.ppm", &.{}); - try std.testing.expectEqualStrings(" New Newtty Del", Pardes.curTail(image_doc)); + try std.testing.expectEqualStrings(" Tty Del Collapse", Pardes.curTail(image_doc)); p.restoreDumpTail(image_doc, .{ .kind = .image, .tag = "img /tmp/legacy image.ppm Keep Del", @@ -1236,14 +1336,14 @@ test "Joincol folds the active column into its right neighbor, keeping its panes try std.testing.expectEqual(@as(usize, 1), p.ncol); } -test "Newtty spawns a raw shell in the caller's directory" { +test "Tty spawns a raw shell in the caller's directory" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 }); defer p.deinit(); while (p.nextEffect()) |_| {} p.setCwd(0, "/tmp/newtty-dir"); - p.spawnTty(0); + try std.testing.expect(p.executeBuiltinLine(0, "Tty")); const free = p.active; try std.testing.expect(free != 0); const nt = p.panes[free] orelse return error.NoNewPane; @@ -1261,6 +1361,85 @@ test "Newtty spawns a raw shell in the caller's directory" { try std.testing.expect(found); } +test "first document fallback splits like Tty from the originating pane" { + for ([_]u16{ 100, 198 }) |width| for (0..3) |from| { + for ([_]bool{ false, true }) |collapsed| { + const p = try Pardes.init(std.testing.allocator, .{ .cols = width, .rows = 62, .shells = 3 }); + defer p.deinit(); + const tty = try Pardes.init(std.testing.allocator, .{ .cols = width, .rows = 62, .shells = 3 }); + defer tty.deinit(); + if (collapsed) { + layout.toggleCollapse(p, from); + layout.toggleCollapse(tty, from); + } + p.sync(); + tty.sync(); + const before = p.rects; + const parent = layout.splitParent(p, from); + const at = layout.findPane(p, parent).?; + // The Look origin need not be the currently active pane. + p.active = (from + 1) % 3; + const free = p.freeSlot().?; + const doc = try p.openTutorView(free); + p.placeDoc(from, free, doc); + tty.spawnTty(from); + p.sync(); + tty.sync(); + try std.testing.expectEqual(@as(usize, 2), p.ncol); + try std.testing.expectEqual(free, p.active); + try std.testing.expectEqual(free, p.col_panes[at.col][at.idx + 1]); + for (0..3) |id| { + try std.testing.expect(p.panes[id] != null); + if (id != parent) try std.testing.expectEqualDeep(before[id], p.rects[id]); + } + for (0..4) |id| try std.testing.expectEqualDeep(tty.rects[id], p.rects[id]); + for (0..p.ncol) |col| { + try std.testing.expectEqualSlices(usize, tty.col_panes[col][0..tty.col_n[col]], p.col_panes[col][0..p.col_n[col]]); + } + } + }; +} + +test "first document keeps its originating shell and only opens a column when it fits" { + for ([_]u16{ 199, 200 }) |width| { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = width, .rows = 32 }); + defer p.deinit(); + const shell = p.panes[0].?; + const doc = try p.openTutorView(1); + p.placeDoc(0, 1, doc); + p.sync(); + try std.testing.expectEqual(shell, p.panes[0].?); + try std.testing.expectEqual(@as(usize, 1), p.active); + if (width < 200) { + try std.testing.expectEqual(@as(usize, 1), p.ncol); + try std.testing.expectEqualSlices(usize, &.{ 0, 1 }, p.col_panes[0][0..p.col_n[0]]); + try std.testing.expectEqual(p.rects[0].y + p.rects[0].h, p.rects[1].y); + } else { + try std.testing.expectEqual(@as(usize, 2), p.ncol); + try std.testing.expectEqual(@as(usize, 1), p.col_panes[0][0]); + try std.testing.expectEqual(@as(usize, 0), p.col_panes[1][0]); + } + } +} + +test "pane defaults keep creation of scratch panes in column tags" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + for ([_][]const u8{ pane_tail, file_pane_tail, pdf_pane_tail, terminal_pane_tail, config.topbar_str }) |tag| { + var words = std.mem.tokenizeScalar(u8, tag, ' '); + while (words.next()) |word| try std.testing.expect(!std.mem.eql(u8, word, "New")); + } + try std.testing.expectEqualStrings("New Tty Find Grep Joincol", p.columnTag(0)); + const terminal = p.panes[0].?; + p.restoreTailAt(terminal, pre_column_terminal_pane_tail, "", .terminal); + try std.testing.expect(!terminal.tag_init); + try std.testing.expectEqualStrings(terminal_pane_tail, Pardes.curTail(terminal)); + const file = try p.setTestFile("unchanged\n"); + p.restoreTailAt(file, pre_column_file_pane_tail, "", .file); + try std.testing.expect(!file.tag_init); + try std.testing.expectEqualStrings(file_pane_tail, Pardes.curTail(file)); +} + test "an unsaved file marker sits between its path and builtins until Save" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 }); @@ -1904,7 +2083,7 @@ test "Save on an output buffer writes its rows out and leaves the buffer alone" defer gpa.free(rows); // Save leads its tagline now, and the path is REQUIRED: a bare Save asks - try std.testing.expectEqualStrings(" Save New Newtty Del", Pardes.curTail(out)); + try std.testing.expectEqualStrings(" Save Tty Del Collapse", Pardes.curTail(out)); try std.testing.expect(p.executeBuiltinLine(id, "Save")); try std.testing.expect(out.prompt == .save); try std.testing.expect(out.appendTag("help.txt")); @@ -2092,7 +2271,7 @@ test "Save reaches every tagline with text behind it and no other" { const out = try p.setTestFile("build.zig:1:1 pub fn main\n"); out.file.?.output = .{ .from = .search }; - try std.testing.expectEqualStrings(" Save New Newtty Del", Pardes.curTail(out)); + try std.testing.expectEqualStrings(" Save Tty Del Collapse", Pardes.curTail(out)); // A dump written before Save reached output buffers carries the generic // tail. That is a DEFAULT and not something typed, so it upgrades. @@ -2102,7 +2281,7 @@ test "Save reaches every tagline with text behind it and no other" { }); p.restoreTail(out, dumped); try std.testing.expect(!out.tag_init); - try std.testing.expectEqualStrings(" Save New Newtty Del", Pardes.curTail(out)); + try std.testing.expectEqualStrings(" Save Tty Del Collapse", Pardes.curTail(out)); // A scratch is an output buffer that has worn the FILE tails all along, // so its own older default upgrades from that family too. @@ -2115,13 +2294,13 @@ test "Save reaches every tagline with text behind it and no other" { }); p.restoreTail(out, scratch_dumped); try std.testing.expect(!out.tag_init); - try std.testing.expectEqualStrings(" Save New Newtty Del", Pardes.curTail(out)); + try std.testing.expectEqualStrings(" Save Tty Del Collapse", Pardes.curTail(out)); // An image's bytes on disk already are what they are: nothing of the // pane's own is unwritten, so the word is absent and inert. const img_id = p.freeSlot().?; const img = try panes.Image.create(p, img_id, "/tmp/pardes-tag.ppm", &.{}); - try std.testing.expectEqualStrings(" New Newtty Del", Pardes.curTail(img)); + try std.testing.expectEqualStrings(" Tty Del Collapse", Pardes.curTail(img)); try std.testing.expect(p.executeBuiltinLine(img_id, "Save")); try std.testing.expect(img.prompt != .save); var buf: [256]u8 = undefined; @@ -2254,6 +2433,7 @@ test "Font keeps requested and acknowledged faces as separate plain state" { try std.testing.expect(p.settings.font.pending); try std.testing.expectEqualStrings(installed[0].name, p.settings.font.requested_name.get()); try std.testing.expectEqualStrings(installed[0].path, p.settings.font.requested_path.get()); + try std.testing.expectEqual(@as(u16, 0), p.settings.font.requested_size_hundredths); try std.testing.expectEqualStrings("", p.settings.font.effective_name.get()); // Boot/zoom/display-scale reports describe the face already being drawn; @@ -2302,6 +2482,48 @@ test "Font keeps requested and acknowledged faces as separate plain state" { try std.testing.expectEqualStrings(installed[0].name, p.settings.font.effective_name.get()); } +test "Font size suffix reaches native request and invalid input leaves it intact" { + if (comptime !font_picker) return; + const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 }); + defer p.deinit(); + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + const installed = fonts.list(arena.allocator(), null); + if (installed.len == 0) return; + var line: [512]u8 = undefined; + try std.testing.expect(p.executeBuiltinLine(p.active, try std.fmt.bufPrint(&line, "Font {s}:18.5", .{installed[0].name}))); + try std.testing.expectEqual(@as(u16, 1850), p.settings.font.requested_size_hundredths); + try std.testing.expectEqualStrings(installed[0].path, p.takeFontRequest().?); + try std.testing.expect(p.acknowledgeFont(installed[0].name, 1850, .pixels)); + try std.testing.expect(p.executeBuiltinLine(p.active, try std.fmt.bufPrint(&line, "Font {s}:nan", .{installed[0].name}))); + try std.testing.expectEqual(@as(u16, 1850), p.settings.font.requested_size_hundredths); + try std.testing.expect(p.takeFontRequest() == null); + try std.testing.expect(p.executeBuiltinLine(p.active, "Font zzz-no-such-face:20")); + try std.testing.expectEqual(@as(u16, 1850), p.settings.font.requested_size_hundredths); + try std.testing.expect(p.takeFontRequest() == null); + try std.testing.expect(p.executeBuiltinLine(p.active, try std.fmt.bufPrint(&line, "Font {s}", .{installed[0].name}))); + try std.testing.expectEqual(@as(u16, 0), p.settings.font.requested_size_hundredths); + try std.testing.expectEqual(@as(u16, 1850), p.settings.font.effective_size_hundredths); +} + +test "Pet command is SDL-only optional and validates its argument" { + const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 }); + defer p.deinit(); + try std.testing.expectEqual(.off, p.settings.pet); + if (comptime platform != .gui) { + try std.testing.expect(std.meta.stringToEnum(builtins.registry.Builtin(), "Pet") == null); + return; + } + try std.testing.expect(p.executeBuiltinLine(p.active, "Pet cat")); + try std.testing.expectEqual(.cat, p.settings.pet); + try std.testing.expect(p.executeBuiltinLine(p.active, "Pet dog")); + try std.testing.expectEqual(.cat, p.settings.pet); + try std.testing.expect(p.executeBuiltinLine(p.active, "Pet frog")); + try std.testing.expectEqual(.frog, p.settings.pet); + try std.testing.expect(p.executeBuiltinLine(p.active, "Pet off")); + try std.testing.expectEqual(.off, p.settings.pet); +} + test "startup config overrides restored styling before initFromDump returns" { const source = try Pardes.init(std.testing.allocator, .{ .startup_config = "Theme dark\n" }); defer source.deinit(); @@ -2589,9 +2811,39 @@ pub const Theme = struct { scroll_track: [3]u8, scroll_thumb: [3]u8, palette: ?[16][3]u8, + + // Pardes UI roles. Optional so existing ThemeFile documents and imported + // editor palettes keep working; ChromeTheme resolves their legacy defaults. + tag_active_bg: ?[3]u8 = null, + tag_active_fg: ?[3]u8 = null, + border: ?[3]u8 = null, + search_bg: ?[3]u8 = null, + search_fg: ?[3]u8 = null, + diagnostic_error: ?[3]u8 = null, + diagnostic_warning: ?[3]u8 = null, + diagnostic_info: ?[3]u8 = null, + diagnostic_hint: ?[3]u8 = null, + // The current line number is a quiet foreground cue, not a tag-colored + // background block. Older themes fall back to their normal gutter ink. + lineno_active: ?[3]u8 = null, }; const curated = struct { + pub const orchard = @import("themes/orchard.zig"); + pub const dusk = @import("themes/dusk.zig"); + pub const ink = @import("themes/ink.zig"); + pub const paper = @import("themes/paper.zig"); + pub const daybreak = @import("themes/daybreak.zig"); + pub const atelier = @import("themes/atelier.zig"); + pub const forge = @import("themes/forge.zig"); + pub const lagoon = @import("themes/lagoon.zig"); + pub const solarium = @import("themes/solarium.zig"); + pub const spectrum = @import("themes/spectrum.zig"); + pub const harvest = @import("themes/harvest.zig"); + pub const clay = @import("themes/clay.zig"); + pub const forge_black = @import("themes/forge_black.zig"); + pub const forge_soft = @import("themes/forge_soft.zig"); + pub const orchard_black = @import("themes/orchard_black.zig"); pub const helix = @import("themes/helix.zig"); pub const dark = @import("themes/dark.zig"); pub const acme = @import("themes/acme.zig"); @@ -2603,8 +2855,15 @@ fn fold(comptime C: type) [@typeInfo(C).@"struct".decls.len]Theme { var out: [@typeInfo(C).@"struct".decls.len]Theme = undefined; for (@typeInfo(C).@"struct".decls, 0..) |d, i| { var t: Theme = undefined; - for (@typeInfo(Theme).@"struct".fields) |f| - @field(t, f.name) = @field(@field(C, d.name).theme, f.name); + const source = @field(C, d.name).theme; + for (@typeInfo(Theme).@"struct".fields) |f| { + @field(t, f.name) = if (@hasField(@TypeOf(source), f.name)) + @field(source, f.name) + else if (f.default_value_ptr) |ptr| + @as(*const f.type, @ptrCast(@alignCast(ptr))).* + else + @compileError("theme " ++ d.name ++ " is missing " ++ f.name); + } out[i] = t; } return out; @@ -2613,6 +2872,134 @@ fn fold(comptime C: type) [@typeInfo(C).@"struct".decls.len]Theme { pub const themes = fold(curated) ++ fold(@import("generated_themes")); +/// Original and classic-inspired Pardes palettes precede retained imports. +pub const native_theme_count = 15; + +fn themeContrast(a: [3]u8, b: [3]u8) f64 { + var luminance: [2]f64 = .{ 0, 0 }; + for ([2][3]u8{ a, b }, 0..) |rgb, i| { + for (rgb, [3]f64{ 0.2126, 0.7152, 0.0722 }) |channel, weight| { + const srgb = @as(f64, @floatFromInt(channel)) / 255; + const linear = if (srgb <= 0.04045) srgb / 12.92 else std.math.pow(f64, (srgb + 0.055) / 1.055, 2.4); + luminance[i] += weight * linear; + } + } + return (@max(luminance[0], luminance[1]) + 0.05) / (@min(luminance[0], luminance[1]) + 0.05); +} + +test "native themes keep text and UI roles readable on their own surfaces" { + const names = [_][]const u8{ "orchard", "dusk", "ink", "paper", "daybreak", "atelier", "forge", "lagoon", "solarium", "spectrum", "harvest", "clay", "forge_black", "forge_soft", "orchard_black" }; + try std.testing.expectEqual(names.len, native_theme_count); + for (themes[0..names.len], names) |th, name| { + try std.testing.expectEqualStrings(name, th.name); + const chrome = ChromeTheme.fromTheme(&th); + const minimum: f64 = if (std.mem.eql(u8, name, "ink") or std.mem.eql(u8, name, "daybreak")) 7 else 4.5; + const text_colors = [_][3]u8{ + th.fg.?, th.kw, th.str, th.num, th.comment, + chrome.diagnostic_error, chrome.diagnostic_warning, chrome.diagnostic_info, chrome.diagnostic_hint, + }; + for (text_colors) |fg| try std.testing.expect(themeContrast(fg, th.bg.?) >= minimum); + // Gutters are orientation aids, not source text: stay visible without + // competing with comments or pulling attention away from the body. + try std.testing.expect(themeContrast(th.lineno, th.bg.?) >= 2.5); + try std.testing.expect(themeContrast(th.lineno, th.bg.?) <= 4); + try std.testing.expect(themeContrast(th.lineno_active.?, th.bg.?) >= themeContrast(th.lineno, th.bg.?)); + try std.testing.expect(themeContrast(th.lineno_active.?, th.bg.?) <= 4.5); + try std.testing.expect(themeContrast(th.comment, th.bg.?) >= 5); + try std.testing.expect(themeContrast(th.comment, th.bg.?) >= themeContrast(th.lineno, th.bg.?) + 2); + try std.testing.expect(themeContrast(th.tag_fg, th.tag_bg) >= minimum); + try std.testing.expect(themeContrast(chrome.tag_active_fg, chrome.tag_active_bg) >= minimum); + // Focus stays on the same tonal surface, rather than inverting a + // whole command strip. Text remains readable in both states. + try std.testing.expect(themeContrast(chrome.tag_active_bg, th.tag_bg) <= 1.6); + try std.testing.expect(themeContrast(chrome.tag_active_fg, th.tag_fg) <= 1.6); + try std.testing.expect(themeContrast(th.sel_fg, th.sel_bg) >= minimum); + try std.testing.expect(themeContrast(chrome.search_fg, chrome.search_bg) >= minimum); + // ANSI black can intentionally match the page (for backgrounds). All + // other ANSI foregrounds, including bright black, remain legible. + for (th.palette.?[1..]) |fg| try std.testing.expect(themeContrast(fg, th.bg.?) >= minimum); + } +} + +test "native dark palettes choose near-black or deliberately soft contrast" { + for (themes[0..native_theme_count]) |th| { + if (themeContrast(th.bg.?, .{ 0, 0, 0 }) > 10) continue; // light paper + const soft = std.mem.eql(u8, th.name, "dusk") or std.mem.eql(u8, th.name, "lagoon") or + std.mem.eql(u8, th.name, "solarium") or std.mem.eql(u8, th.name, "forge_soft"); + if (soft) { + try std.testing.expect(themeContrast(th.fg.?, th.bg.?) >= 4.5); + try std.testing.expect(themeContrast(th.fg.?, th.bg.?) <= 7); + } else { + for (th.bg.?) |channel| try std.testing.expect(channel <= 0x14); + try std.testing.expect(themeContrast(th.fg.?, th.bg.?) >= 14); + } + } + const forge = curated.forge.theme; + try std.testing.expectEqual(forge.bg[0], forge.bg[1]); + // Forge chrome is cool-neutral, never the old green-over-blue mint cast. + for ([_][3]u8{ forge.tag_bg, forge.tag_active_bg, forge.box, forge.border }) |rgb| + try std.testing.expect(rgb[2] >= rgb[1]); + try std.testing.expectEqual([3]u8{ 0, 0, 0 }, @as([3]u8, curated.forge_black.theme.bg)); + try std.testing.expectEqual([3]u8{ 0, 0, 0 }, @as([3]u8, curated.orchard_black.theme.bg)); +} + +test "line numbers stay quiet without a current-line tag background" { + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 }); + defer p.deinit(); + const pane = try p.setTestFile("first\nsecond\n"); + pane.cur_pinned = true; + pane.cur_row = 0; + var custom = themes[0]; + custom.name = try gpa.dupe(u8, "quiet-gutter"); + custom.lineno = .{ 70, 70, 70 }; + custom.lineno_active = .{ 95, 95, 95 }; + p.custom_theme = custom; + p.sync(); + const x = p.rects[0].x + config.GUTTER; + const y = p.rects[0].y + BOX_H; + for ([_]bool{ false, true }) |focus_tint| { + p.settings.focus_tint = focus_tint; + const surface = try p.render(p.scratch.allocator()); + try std.testing.expectEqual(Color{ .rgb = .{ 95, 95, 95 } }, surface.at(x, y).style.fg); + try std.testing.expectEqual(Color{ .rgb = .{ 70, 70, 70 } }, surface.at(x, y + 1).style.fg); + try std.testing.expectEqual(Color{ .rgb = custom.bg.? }, surface.at(x, y).style.bg); + try std.testing.expect(surface.at(x, y).style.bold); + try std.testing.expect(!surface.at(x, y + 1).style.bold); + } + // Old ThemeFile palettes need no new field, and do not regain the block. + p.custom_theme.?.lineno_active = null; + const fallback = try p.render(p.scratch.allocator()); + try std.testing.expectEqual(Color{ .rgb = .{ 70, 70, 70 } }, fallback.at(x, y).style.fg); + try std.testing.expectEqual(Color{ .rgb = custom.bg.? }, fallback.at(x, y).style.bg); + try std.testing.expect(fallback.at(x, y).style.bold); + // Moving the caret clears the previous cue; editing the tag parks it. + pane.cur_row = 1; + const moved = try p.render(p.scratch.allocator()); + try std.testing.expect(!moved.at(x, y).style.bold); + try std.testing.expect(moved.at(x, y + 1).style.bold); + p.enterTagEdit(pane, -1); + const tag_edit = try p.render(p.scratch.allocator()); + try std.testing.expect(!tag_edit.at(x, y + 1).style.bold); +} + +test "legacy ThemeFile documents inherit Pardes UI roles without new fields" { + @setEvalBranchQuota(10000); + var encoded: std.Io.Writer.Allocating = .init(std.testing.allocator); + defer encoded.deinit(); + try std.zon.stringify.serialize(curated.helix.theme, .{ .whitespace = true }, &encoded.writer); + const source = try std.testing.allocator.dupeZ(u8, encoded.written()); + defer std.testing.allocator.free(source); + const parsed = try std.zon.parse.fromSliceAlloc(Theme, std.testing.allocator, source, null, .{}); + defer std.zon.parse.free(std.testing.allocator, parsed); + try std.testing.expectEqual(@as(?[3]u8, null), parsed.tag_active_bg); + try std.testing.expectEqual(@as(?[3]u8, null), parsed.search_bg); + const chrome = ChromeTheme.fromTheme(&parsed); + try std.testing.expectEqual(parsed.tag_bg, chrome.tag_active_bg); + try std.testing.expectEqual(parsed.sel_bg, chrome.search_bg); + try std.testing.expectEqual(parsed.fg.?, chrome.diagnostic_error); +} + comptime { if (themes.len > std.math.maxInt(u16) + 1) @compileError("runtime config theme index no longer fits u16"); @@ -2667,6 +3054,15 @@ test "every line of the board's boot buffer renders whole" { pub const ChromeTheme = struct { tag_bg: [3]u8, tag_fg: [3]u8, + tag_active_bg: [3]u8, + tag_active_fg: [3]u8, + border: [3]u8, + search_bg: [3]u8, + search_fg: [3]u8, + diagnostic_error: [3]u8, + diagnostic_warning: [3]u8, + diagnostic_info: [3]u8, + diagnostic_hint: [3]u8, box: [3]u8, box_dim: [3]u8, lineno: [3]u8, @@ -2677,6 +3073,15 @@ pub const ChromeTheme = struct { return .{ .tag_bg = th.tag_bg, .tag_fg = th.tag_fg, + .tag_active_bg = th.tag_active_bg orelse th.tag_bg, + .tag_active_fg = th.tag_active_fg orelse th.tag_fg, + .border = th.border orelse th.scroll_track, + .search_bg = th.search_bg orelse th.sel_bg, + .search_fg = th.search_fg orelse th.sel_fg, + .diagnostic_error = th.diagnostic_error orelse (th.fg orelse th.tag_fg), + .diagnostic_warning = th.diagnostic_warning orelse (th.fg orelse th.tag_fg), + .diagnostic_info = th.diagnostic_info orelse (th.fg orelse th.tag_fg), + .diagnostic_hint = th.diagnostic_hint orelse (th.fg orelse th.tag_fg), .box = th.box, .box_dim = th.box_dim, .lineno = th.lineno, @@ -3459,6 +3864,8 @@ test "tabbed file aligns syntax cursor and mouse while preserving virtual column const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .cols = 60, .rows = 12 }); defer p.deinit(); + // Bold is an explicit preference; use it to expose the glyph boundaries. + try std.testing.expect(p.executeBuiltinLine(0, "SyntaxBold")); const pane = try p.setTestFile("\treturn x\n"); const f = &pane.file.?; f.highlights = try p.tree_sitter_gpa.alloc(u8, f.content.len); @@ -3685,6 +4092,207 @@ test "selection drag and queued release do not enter panel pending state" { try std.testing.expectEqual(.done, pane.sel[sel_slot].state); } +test "editable workspace and column tags replace selections and persist" { + const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 20, .tty_only = true }); + defer p.deinit(); + _ = try p.setTestFile("untouched\n"); + p.acknowledgePanelPresentation(&.{}); + try std.testing.expectEqual(TOPBAR_H + COLBAR_H, p.rects[0].y); + p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = 0, .row = 0 } }); + p.update(.{ .mouse = .{ .button = .left, .kind = .drag, .col = 6, .row = 0 } }); + p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = 6, .row = 0 } }); + p.update(.{ .key = .{ .cp = 'H', .text = "Help" } }); + try std.testing.expect(std.mem.startsWith(u8, p.global_tag.slice(""), "Help Joincol")); + p.update(.{ .key = .{ .cp = Key.escape } }); + p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = 0, .row = TOPBAR_H } }); + p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = 0, .row = TOPBAR_H } }); + p.update(.{ .key = .{ .cp = 'é', .text = "é" } }); + try std.testing.expect(std.mem.startsWith(u8, p.columnTag(0), "éNew")); + p.update(.{ .key = .{ .cp = Key.backspace } }); + try std.testing.expect(std.mem.startsWith(u8, p.columnTag(0), "New")); + p.update(.{ .paste = "Grep " }); + try std.testing.expect(std.mem.startsWith(u8, p.columnTag(0), "Grep New")); + try std.testing.expectEqualStrings("untouched\n", p.panes[0].?.file.?.content); + try p.dumpState(); + const restored = try p.restore(p.dump_out.?); + defer restored.deinit(); + try std.testing.expectEqualStrings(p.global_tag.slice(""), restored.global_tag.slice("")); + try std.testing.expectEqualStrings(p.columnTag(0), restored.columnTag(0)); + try std.testing.expect(p.col_tags[0].set("")); + try p.dumpState(); + const empty = try p.restore(p.dump_out.?); + defer empty.deinit(); + try std.testing.expectEqualStrings("", empty.columnTag(0)); +} + +test "column tags follow their column through split and close" { + const p = try Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 20, .tty_only = true }); + defer p.deinit(); + _ = try p.setTestFile("left\n"); + try std.testing.expect(p.col_tags[0].set("Left Find")); + _ = p.execute(0, "Newcol"); + try std.testing.expectEqual(@as(usize, 2), p.ncol); + try std.testing.expectEqualStrings("Left Find", p.columnTag(0)); + try std.testing.expect(p.col_tags[1].set("Right Grep")); + p.active = p.col_panes[0][0]; + layout.joinCol(p); + try std.testing.expectEqual(@as(usize, 1), p.ncol); + try std.testing.expectEqualStrings("Right Grep", p.columnTag(0)); +} + +test "column tags render edit hover and scroll from the column edge" { + const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 20, .tty_only = true }); + defer p.deinit(); + _ = try p.setTestFile("untouched\n"); + p.newScratchColumn(0); + p.sync(); + p.acknowledgePanelPresentation(&.{}); + var frame = std.heap.ArenaAllocator.init(std.testing.allocator); + defer frame.deinit(); + for (0..p.ncol) |column| { + const x = p.col_x[column]; + const width = p.col_w[column]; + const initial = try p.render(frame.allocator()); + for ("New", 0..) |byte, offset| + try std.testing.expectEqual(byte, initial.at(x + @as(u16, @intCast(offset)), TOPBAR_H).text[0]); + p.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = x, .row = TOPBAR_H } }); + const hovered = try p.render(frame.allocator()); + try std.testing.expectEqual(Color{ .rgb = p.theme().sel_bg }, hovered.at(x, TOPBAR_H).style.bg); + p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = x, .row = TOPBAR_H } }); + try std.testing.expectEqual(column, p.header_column.?); + try std.testing.expectEqual(@as(u16, 0), p.topbar_col.?); + const at_start = try p.render(frame.allocator()); + try std.testing.expectEqual(x, at_start.cursor.?.x); + p.update(.{ .mouse = .{ .button = .left, .kind = .drag, .col = x + 3, .row = TOPBAR_H } }); + p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = x + 3, .row = TOPBAR_H } }); + const selected = try p.render(frame.allocator()); + for (0..3) |offset| + try std.testing.expectEqual(Color{ .rgb = p.theme().sel_bg }, selected.at(x + @as(u16, @intCast(offset)), TOPBAR_H).style.bg); + p.update(.{ .key = .{ .cp = 'H', .text = "Help" } }); + try std.testing.expect(std.mem.startsWith(u8, p.columnTag(column), "Help Tty")); + const edited = try p.render(frame.allocator()); + try std.testing.expectEqual(x + 4, edited.cursor.?.x); + // The two reclaimed cells belong to text and caret reveal, too. + const long: [80]u8 = @splat('a'); + try std.testing.expect(p.col_tags[column].set(long[0 .. width - 1])); + p.update(.{ .key = .{ .cp = Key.end } }); + const at_end = try p.render(frame.allocator()); + try std.testing.expectEqual(@as(usize, 0), p.header_scroll); + try std.testing.expectEqual(x + width - 1, at_end.cursor.?.x); + p.update(.{ .key = .{ .cp = 'b', .text = "b" } }); + const revealed = try p.render(frame.allocator()); + try std.testing.expectEqual(@as(usize, 1), p.header_scroll); + try std.testing.expectEqual(x + width - 1, revealed.cursor.?.x); + p.update(.{ .key = .{ .cp = Key.home } }); + const home = try p.render(frame.allocator()); + try std.testing.expectEqual(@as(usize, 0), p.header_scroll); + try std.testing.expectEqual(x, home.cursor.?.x); + p.update(.{ .key = .{ .cp = Key.escape } }); + p.update(.pointer_leave); + _ = frame.reset(.retain_capacity); + } + try std.testing.expectEqualStrings("untouched\n", p.panes[0].?.file.?.content); +} + +test "column tag first-cell execution keeps New local to the clicked column" { + const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 20, .tty_only = true }); + defer p.deinit(); + _ = try p.setTestFile("untouched\n"); + p.newScratchColumn(0); + p.sync(); + for (0..2) |column| { + p.active = p.col_panes[1 - column][0]; + p.acknowledgePanelPresentation(&.{}); + const before = p.col_n; + p.update(.{ .mouse = .{ .button = .middle, .kind = .press, .col = p.col_x[column], .row = TOPBAR_H } }); + p.update(.{ .mouse = .{ .button = .middle, .kind = .release, .col = p.col_x[column], .row = TOPBAR_H } }); + try std.testing.expectEqual(before[column] + 1, p.col_n[column]); + try std.testing.expectEqual(before[1 - column], p.col_n[1 - column]); + try std.testing.expectEqual(column, layout.findPane(p, p.active).?.col); + } + try std.testing.expectEqualStrings("untouched\n", p.panes[0].?.file.?.content); +} + +test "header clicks beyond text append and long UTF-8 tags reveal their caret" { + const p = try Pardes.init(std.testing.allocator, .{ .cols = 50, .rows = 12, .tty_only = true }); + defer p.deinit(); + _ = try p.setTestFile("body\n"); + try std.testing.expect(p.col_tags[0].set("New")); + p.acknowledgePanelPresentation(&.{}); + p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = 40, .row = TOPBAR_H } }); + p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = 40, .row = TOPBAR_H } }); + p.update(.{ .key = .{ .cp = 'x', .text = " extended" } }); + try std.testing.expectEqualStrings("New extended", p.columnTag(0)); + p.update(.{ .paste = " 界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界" }); + const frame = try p.render(p.scratch.allocator()); + try std.testing.expect(p.header_scroll > 0); + try std.testing.expect(frame.cursor != null); + try std.testing.expectEqual(TOPBAR_H, frame.cursor.?.y); + try std.testing.expect(frame.cursor.?.x < p.screen_w); + p.update(.{ .key = .{ .cp = Key.home } }); + _ = try p.render(p.scratch.allocator()); + try std.testing.expectEqual(@as(usize, 0), p.header_scroll); +} + +test "tiny screens keep pane space before adding a column tag" { + const p = try Pardes.init(std.testing.allocator, .{ .cols = 8, .rows = 2, .tty_only = true }); + defer p.deinit(); + try std.testing.expectEqual(@as(u16, 0), p.columnBarHeight()); + try std.testing.expectEqual(TOPBAR_H, p.rects[0].y); + _ = try p.render(p.scratch.allocator()); +} + +test "ColumnTags reclaims the row and releases hidden header focus without losing commands" { + const p = try Pardes.init(std.testing.allocator, .{ .cols = 60, .rows = 12, .tty_only = true }); + defer p.deinit(); + const pane = try p.setTestFile("unchanged body\n"); + try std.testing.expect(p.col_tags[0].set("New Help")); + p.acknowledgePanelPresentation(&.{}); + const shown = p.rects[0]; + try std.testing.expectEqual(TOPBAR_H + COLBAR_H, shown.y); + p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = config.GUTTER, .row = TOPBAR_H } }); + p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = config.GUTTER, .row = TOPBAR_H } }); + try std.testing.expect(p.header_column != null and p.topbar_col != null); + + p.update(.{ .command = "ColumnTags" }); + try std.testing.expect(!p.settings.column_tags); + try std.testing.expectEqual(@as(u16, 0), p.columnBarHeight()); + try std.testing.expectEqual(TOPBAR_H, p.rects[0].y); + try std.testing.expectEqual(shown.h + COLBAR_H, p.rects[0].h); + try std.testing.expect(p.header_column == null and p.topbar_col == null); + try std.testing.expect(p.header_anchor == null and !p.header_drag); + try std.testing.expectEqualStrings("New Help", p.columnTag(0)); + + p.update(.{ .command = "ColumnTags" }); + try std.testing.expect(p.settings.column_tags); + try std.testing.expectEqual(shown, p.rects[0]); + p.acknowledgePanelPresentation(&.{}); + p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = config.GUTTER, .row = TOPBAR_H } }); + p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = config.GUTTER, .row = TOPBAR_H } }); + try std.testing.expect(p.header_column != null); + p.update(.{ .resize = .{ .cols = 60, .rows = 2 } }); + try std.testing.expect(p.settings.column_tags); + try std.testing.expectEqual(@as(u16, 0), p.columnBarHeight()); + try std.testing.expectEqual(TOPBAR_H, p.rects[0].y); + try std.testing.expect(p.header_column == null and p.topbar_col == null); + p.update(.{ .resize = .{ .cols = 60, .rows = 12 } }); + try std.testing.expectEqual(shown, p.rects[0]); + try std.testing.expectEqualStrings("New Help", p.columnTag(0)); + try std.testing.expectEqualStrings("unchanged body\n", pane.file.?.content); +} + +test "normal header Enter executes its command rather than searching for it" { + const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 12, .tty_only = true }); + defer p.deinit(); + _ = try p.setTestFile("body\n"); + p.topbar_col = @intCast(std.mem.indexOf(u8, config.topbar_str, "Dump").?); + p.header_insert = false; + p.update(.{ .key = .{ .cp = Key.enter } }); + try std.testing.expect(p.topbar_col == null); + try std.testing.expect(p.dump_out != null); + try std.testing.expectEqual(@as(usize, 1), p.col_n[0]); +} + pub const Mouse = struct { // sel indexing relies on left/middle/right being 0/1/2 pub const Button = enum { left, middle, right, wheel_up, wheel_down, wheel_left, wheel_right, none }; @@ -5041,6 +5649,8 @@ pub const Pardes = struct { col_weight: [MAX_COLS]u64 = @splat(column_weight_unit), col_panes: [MAX_COLS][MAX_PANES]usize = undefined, col_n: [MAX_COLS]usize = @splat(0), + col_tags: [MAX_COLS]TagLine = @splat(.{}), + global_tag: TagLine = .{}, // derived each sync rects: [MAX_PANES]Rect = @splat(.{}), col_x: [MAX_COLS]u16 = undefined, @@ -5087,6 +5697,11 @@ pub const Pardes = struct { leader_keys: [4]u8 = undefined, leader_n: u8 = 0, topbar_col: ?u16 = null, + header_column: ?usize = null, + header_insert: bool = false, + header_anchor: ?u16 = null, + header_drag: bool = false, + header_scroll: usize = 0, ov_pinch_scale: f32 = 1.0, ov_touch_scroll_delta: f32 = 0.0, /// middle+left chord argument: a kept left selection captured by a left @@ -5329,11 +5944,57 @@ pub const Pardes = struct { /// the topbar line: the fixed builtins, plus `Restore ` once a dump /// exists — render and click dispatch must agree on this exact string fn topbar(p: *Pardes, buf: []u8) []const u8 { + if (p.global_tag.custom) return p.global_tag.slice(""); if (p.last_dump) |d| return std.fmt.bufPrint(buf, config.topbar_str ++ " Restore {s}", .{d}) catch config.topbar_str; return config.topbar_str; } + pub fn columnBarHeight(p: *const Pardes) u16 { + return if (p.settings.column_tags and p.screen_h >= 3) COLBAR_H else 0; + } + + pub fn columnTag(p: *const Pardes, c: usize) []const u8 { + return p.col_tags[c].slice("New Tty Find Grep Joincol"); + } + + fn headerText(p: *Pardes) []const u8 { + if (p.header_column) |c| return if (c < p.ncol) p.columnTag(c) else ""; + var buf: [1200]u8 = undefined; + return p.scratch.allocator().dupe(u8, p.topbar(&buf)) catch ""; + } + + fn revealHeader(p: *Pardes) void { + const cur = p.topbar_col orelse { + p.header_scroll = 0; + return; + }; + const text = p.headerText(); + const width = if (p.header_column) |c| p.col_w[c] else p.screen_w; + const col = panes.File.rawDisplayCol(text, @min(cur, text.len)); + if (col < p.header_scroll) p.header_scroll = col; + if (col >= p.header_scroll + width) p.header_scroll = col -| (width -| 1); + var raw = panes.File.rawAtDisplay(text, p.header_scroll); + p.header_scroll = panes.File.rawDisplayCol(text, raw); + if (width > 0 and col >= p.header_scroll + width) { + raw = modal.nextGrapheme(text, raw); + p.header_scroll = panes.File.rawDisplayCol(text, raw); + } + } + + fn headerReplace(p: *Pardes, bytes: []const u8) void { + const cur = p.topbar_col orelse return; + const anchor = p.header_anchor orelse cur; + const lo = @min(cur, anchor); + const hi = @max(cur, anchor); + const fallback = p.headerText(); + const line = if (p.header_column) |c| &p.col_tags[c] else &p.global_tag; + if (line.replace(fallback, lo, hi, bytes)) { + p.topbar_col = @intCast(lo + bytes.len); + p.header_anchor = null; + } + } + fn detachCwds(p: *Pardes, parents: []const *Pane) !void { var copies: [MAX_PANES]?[]u8 = @splat(null); errdefer for (copies) |copy| if (copy) |bytes| p.gpa.free(bytes); @@ -5673,6 +6334,8 @@ pub const Pardes = struct { } pub fn loadThemeFile(p: *Pardes, generation: u32, bytes: []const u8, animate: bool) bool { + // ZON's generated field dispatch grows with the complete theme schema. + @setEvalBranchQuota(10000); const request = p.themeFileRequest(generation) orelse return false; if (bytes.len > 1024 * 1024) { p.reportError(request.pane, "theme file", error.ThemeFileTooLarge); @@ -6243,6 +6906,11 @@ pub const Pardes = struct { } fn applyPaste(p: *Pardes, bytes: []const u8) void { + if (p.topbar_col != null) { + p.clip_pending = null; + p.headerReplace(bytes); + return; + } const req = p.clip_pending; p.clip_pending = null; if (bytes.len == 0) return; @@ -6250,6 +6918,14 @@ pub const Pardes = struct { const pane = p.panes[id] orelse return; if (req) |r| if (pane.serial != r.serial) return; p.active = id; + if (pane.tag_edit) { + const text = p.tagText(p.scratch.allocator(), pane) catch return; + const range: @TypeOf(tagSelBounds(pane)) = if (pane.tag_sel) tagSelBounds(pane) else .{ .lo = pane.tag_col, .hi = pane.tag_col }; + const hi = if (pane.tag_sel) modal.nextGrapheme(text, range.hi) else range.hi; + _ = p.replaceTagText(pane, range.lo, hi, bytes); + if (selRegexArmed(pane)) |a| p.applySelRegex(pane, a.pat, a.split); + return; + } if (pane.isTerminal() and pane.mode == .tty) return p.typeToTty(id, pane, bytes); switch (if (req) |r| r.mode else .after) { .after => p.pasteText(pane, bytes, false, 1), @@ -6286,14 +6962,15 @@ pub const Pardes = struct { const arena = p.scratch.allocator(); if (comptime pdf_enabled) if (pane.pdf) |pv| return std.fmt.allocPrint( arena, - "pdf {d}/{d} {s} PdfFit {s} PdfTint PdfSections {s}", - .{ pv.page + 1, pv.page_count, @tagName(pv.fit), @tagName(pv.tint), pv.path }, + "{s} [{d}/{d}]", + .{ pv.path, pv.page + 1, pv.page_count }, ); if (pane.image) |*state| return panes.Image.tagPrefix(arena, state); if (pane.file) |f| { + const name = if (pane.tag_name_len) |len| pane.tag_name[0..len] else f.path; if (panes.Output.fileTraits(f.output).saves and f.revision != f.saved_revision) - return std.fmt.allocPrint(arena, "{s}{s}", .{ f.path, dirty_marker }); - return arena.dupe(u8, f.path); + return std.fmt.allocPrint(arena, "{s}{s}", .{ name, dirty_marker }); + return arena.dupe(u8, name); } return arena.dupe(u8, pane.cwdSlice()); } @@ -6305,6 +6982,7 @@ pub const Pardes = struct { } fn defaultTail(pane: *const Pane) []const u8 { + if (comptime pdf_enabled) if (pane.pdf != null) return pdf_pane_tail; if (pane.file != null) return file_pane_tail; if (pane.isTerminal()) return terminal_pane_tail; return pane_tail; @@ -6317,45 +6995,21 @@ pub const Pardes = struct { return null; } - fn tagGap(p: *Pardes, pane: *const Pane, used: usize) usize { - if (pane.tag_init) return 0; - const id = p.paneIdOf(pane) orelse return 0; - const r = p.rects[id]; - if (r.w <= config.GUTTER) return 0; - const tw: usize = r.w - config.GUTTER; - var end: usize = tw -| config.tag_right_pad; - for (p.panes, 0..) |slot, qid| if (slot) |q| { - const qr = p.rects[qid]; - if (qr.h == 0 or qr.x != r.x or qr.w != r.w) continue; - 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 = panes.File.displayWidth(p.tagPrefix(q) catch continue) + lead + panes.File.displayWidth(std.mem.trimStart(u8, words, " ")); - end = @max(end, @min(q_end, tw)); - }; - return end -| used; - } - pub fn tagText(p: *Pardes, arena: std.mem.Allocator, pane: *Pane) ![]u8 { const prefix = try p.tagPrefix(pane); const tail = curTail(pane); - const gap = p.tagGap(pane, panes.File.displayWidth(prefix) + panes.File.displayWidth(tail)); - const out = try arena.alloc(u8, prefix.len + gap + tail.len); + const out = try arena.alloc(u8, prefix.len + tail.len); @memcpy(out[0..prefix.len], prefix); - @memset(out[prefix.len..][0..gap], ' '); - @memcpy(out[prefix.len + gap ..], tail); + @memcpy(out[prefix.len..], tail); return out; } - pub fn seedTail(p: *Pardes, pane: *Pane) void { + pub fn seedTail(_: *Pardes, pane: *Pane) void { if (pane.tag_init) return; const tail = curTail(pane); - const prefix = (p.tagPrefix(pane) catch return); - const gap = p.tagGap(pane, panes.File.displayWidth(prefix) + panes.File.displayWidth(tail)); - if (gap + tail.len > pane.tag_tail.len) return; - @memset(pane.tag_tail[0..gap], ' '); - @memcpy(pane.tag_tail[gap..][0..tail.len], tail); - pane.tag_tail_len = gap + tail.len; + if (tail.len > pane.tag_tail.len) return; + @memcpy(pane.tag_tail[0..tail.len], tail); + pane.tag_tail_len = tail.len; pane.tag_init = true; } @@ -6374,9 +7028,16 @@ pub const Pardes = struct { const lead: i32 = @intCast(tail.len - std.mem.trimStart(u8, tail, " ").len); if (col < 0) { pane.tag_col = @intCast(@min(edit0 + lead, end)); + const text = p.tagText(p.scratch.allocator(), pane) catch return; + if (p.paneIdOf(pane)) |id| { + const width = @max(1, p.rects[id].w -| config.GUTTER); + // Reveal the suffix together with its commands, not merely the + // first command letter at the viewport's far right edge. + pane.tag_scroll = @intCast(panes.File.displayWidth(text) + 1 -| width); + } } else { const text = p.tagText(p.scratch.allocator(), pane) catch return; - pane.tag_col = @intCast(@min(text.len, panes.File.rawAtDisplay(text, @intCast(col)))); + pane.tag_col = @intCast(@min(text.len, panes.File.rawAtDisplay(text, @as(usize, @intCast(col)) + pane.tag_scroll))); } } @@ -6386,6 +7047,8 @@ pub const Pardes = struct { } else pane.mode = .normal; pane.tag_edit = false; pane.tag_sel = false; + pane.tag_name_len = null; + pane.tag_scroll = 0; pane.prompt = .none; // an abandoned prompt stays as tag text pane.nsel_snap = 0; // ...and its s/S preview keeps whatever it previewed } @@ -6407,6 +7070,90 @@ pub const Pardes = struct { return if (b.hi > b.lo) text[b.lo..b.hi] else null; } + fn tagNameEnd(pane: *const Pane) ?usize { + const f = pane.file orelse return null; + if (!panes.Output.fileTraits(f.output).saves or f.path.len > pane.tag_name.len) return null; + return if (pane.tag_name_len) |len| len else f.path.len; + } + + /// Commit a staged buffer name, never a disk rename or write. Keep the + /// draft visible on failure so the user can correct it or cancel with Esc. + pub fn commitTagName(p: *Pardes, pane: *Pane) bool { + const len = pane.tag_name_len orelse return true; + const id = p.paneIdOf(pane) orelse return false; + const name = pane.tag_name[0..len]; + if (name.len == 0 or name[name.len - 1] == '/' or !std.unicode.utf8ValidateSlice(name)) { + p.reportError(id, "name", error.InvalidName); + return false; + } + for (name) |byte| if (byte < ' ') { + p.reportError(id, "name", error.InvalidName); + return false; + }; + const old_prefix = (p.tagPrefix(pane) catch return false).len; + const tail_col = @as(usize, pane.tag_col) -| old_prefix; + const full = std.fs.path.resolvePosix(p.scratch.allocator(), &.{ paneDir(pane), name }) catch |err| { + p.reportError(id, "name", err); + return false; + }; + filesystem.nameBuffer(p, id, full, true) catch |err| { + p.reportError(id, "name", err); + return false; + }; + pane.tag_name_len = null; + const prefix = (p.tagPrefix(pane) catch return false).len; + pane.tag_col = @intCast(prefix + @min(tail_col, pane.tag_tail_len)); + pane.tag_sel = false; + return true; + } + + /// Replace one range in the rendered tag. The file name and command tail + /// retain separate identities even when a selection crosses their boundary. + fn replaceTagText(p: *Pardes, pane: *Pane, start: usize, finish: usize, input: []const u8) bool { + const prefix_len = (p.tagPrefix(pane) catch return false).len; + const end = prefix_len + pane.tag_tail_len; + const lo = @min(start, end); + const hi = @max(lo, @min(finish, end)); + const name_end = tagNameEnd(pane); + const editing_name = if (name_end) |n| lo <= n else false; + if (!editing_name and lo < prefix_len) return false; + var bytes: [limits.max_tag_tail]u8 = undefined; + var count: usize = 0; + for (input) |byte| { + if (byte == '\n' or byte == '\r') continue; + if (count == bytes.len) return false; + bytes[count] = byte; + count += 1; + } + const tail_lo = lo -| prefix_len; + const tail_hi = hi -| prefix_len; + const tail_insert = if (editing_name) 0 else count; + const tail_len = pane.tag_tail_len - (tail_hi - tail_lo) + tail_insert; + if (tail_len > pane.tag_tail.len) return false; + if (editing_name) { + const n = name_end.?; + const name_hi = @min(hi, n); + const name_len = n - (name_hi - lo) + count; + if (name_len > pane.tag_name.len) return false; + if (pane.tag_name_len == null) @memcpy(pane.tag_name[0..n], pane.file.?.path); + if (count > name_hi - lo) + std.mem.copyBackwards(u8, pane.tag_name[lo + count .. name_len], pane.tag_name[name_hi..n]) + else + std.mem.copyForwards(u8, pane.tag_name[lo + count .. name_len], pane.tag_name[name_hi..n]); + @memcpy(pane.tag_name[lo..][0..count], bytes[0..count]); + pane.tag_name_len = @intCast(name_len); + } + if (tail_insert > tail_hi - tail_lo) + std.mem.copyBackwards(u8, pane.tag_tail[tail_lo + tail_insert .. tail_len], pane.tag_tail[tail_hi..pane.tag_tail_len]) + else + std.mem.copyForwards(u8, pane.tag_tail[tail_lo + tail_insert .. tail_len], pane.tag_tail[tail_hi..pane.tag_tail_len]); + if (!editing_name) @memcpy(pane.tag_tail[tail_lo..][0..count], bytes[0..count]); + pane.tag_tail_len = tail_len; + pane.tag_col = @intCast(lo + count); + pane.tag_sel = false; + return true; + } + fn tagInsertKey(p: *Pardes, pane: *Pane, key: Key) void { if (hit(key, config.escape)) { exitTagEdit(pane); // the tag is ALWAYS insert; Esc leaves it @@ -6414,44 +7161,47 @@ pub const Pardes = struct { } // the acme chords on the selection or the word at the cursor if (hit(key, config.look_key) or hit(key, config.exec_key)) { + if (pane.tag_name_len != null) { + const in_name = pane.tag_col < (p.tagPrefix(pane) catch return).len; + if (!p.commitTagName(pane)) return; + if (hit(key, config.look_key) or in_name) { + exitTagEdit(pane); + return; + } + } if (p.tagChordText(pane)) |txt| { pane.tag_sel = false; p.runBuiltin(if (hit(key, config.look_key)) config.look_cmd else config.exec_cmd, p.active, "", txt); } return; } - const edit0: u16 = @intCast((p.tagPrefix(pane) catch return).len); - const end: u16 = edit0 + @as(u16, @intCast(pane.tag_tail_len)); + const text = p.tagText(p.scratch.allocator(), pane) catch return; + const end = text.len; if (key.text.len > 0) { - if (pane.tag_col < edit0) return; // typing inside the path: inert - var insert_len: usize = 0; - for (key.text) |ch| insert_len += @intFromBool(ch != '\n' and ch != '\r'); - if (insert_len > pane.tag_tail.len - pane.tag_tail_len) return; - for (key.text) |ch| { - if (ch == '\n' or ch == '\r') continue; // one-line tag - if (!pane.insertTagByte(pane.tag_col - edit0, ch)) return; - pane.tag_col += 1; - } + const range: @TypeOf(tagSelBounds(pane)) = if (pane.tag_sel) tagSelBounds(pane) else .{ .lo = pane.tag_col, .hi = pane.tag_col }; + const hi = if (pane.tag_sel) modal.nextGrapheme(text, range.hi) else range.hi; + _ = p.replaceTagText(pane, range.lo, hi, key.text); return; } switch (key.cp) { - Key.backspace => if (pane.tag_col > edit0) { - const text = p.tagText(p.scratch.allocator(), pane) catch return; - const prev = @max(@as(usize, edit0), modal.prevGrapheme(text, pane.tag_col)); - const count = @as(usize, pane.tag_col) - prev; - for (0..count) |_| pane.removeTagByte(prev - edit0); - pane.tag_col = @intCast(prev); + Key.backspace, Key.delete => { + if (pane.tag_sel) { + const range = tagSelBounds(pane); + _ = p.replaceTagText(pane, range.lo, modal.nextGrapheme(text, range.hi), ""); + } else if (key.cp == Key.backspace and pane.tag_col > 0) { + _ = p.replaceTagText(pane, modal.prevGrapheme(text, pane.tag_col), pane.tag_col, ""); + } else if (key.cp == Key.delete and pane.tag_col < end) { + _ = p.replaceTagText(pane, pane.tag_col, modal.nextGrapheme(text, pane.tag_col), ""); + } }, Key.left => if (pane.tag_col > 0) { - const text = p.tagText(p.scratch.allocator(), pane) catch return; pane.tag_col = @intCast(modal.prevGrapheme(text, pane.tag_col)); }, Key.right => if (pane.tag_col < end) { - const text = p.tagText(p.scratch.allocator(), pane) catch return; pane.tag_col = @intCast(modal.nextGrapheme(text, pane.tag_col)); }, Key.home => pane.tag_col = 0, - Key.end => pane.tag_col = end, + Key.end => pane.tag_col = @intCast(end), else => {}, } } @@ -6486,7 +7236,7 @@ pub const Pardes = struct { if (hit(key, config.insert) or hit(key, config.append) or hit(key, config.insert_line_start) or hit(key, config.insert_line_end)) { - const edit0: usize = (p.tagPrefix(pane) catch return).len; + const edit0: usize = if (tagNameEnd(pane) != null) 0 else (p.tagPrefix(pane) catch return).len; pane.tag_col = @intCast(@max(edit0, if (hit(key, config.append)) modal.nextGrapheme(text, cur) else if (hit(key, config.insert_line_start)) @@ -6509,6 +7259,10 @@ pub const Pardes = struct { if (d == .Up) { exitTagEdit(pane); p.topbar_col = 0; + p.header_column = if (p.columnBarHeight() > 0) (layout.findPane(p, from) orelse return).col else null; + p.header_insert = false; + p.header_anchor = null; + p.header_drag = false; } return; } @@ -6554,25 +7308,83 @@ pub const Pardes = struct { } fn topbarKey(p: *Pardes, key: Key) void { - var tb_buf: [1200]u8 = undefined; - const bar = p.topbar(&tb_buf); + const bar = p.headerText(); // the line GROWS a `Restore ` word the moment a dump lands, so // the column is clamped against the live line on every use const cur: usize = @min(@as(usize, p.topbar_col orelse return), bar.len); if (hit(key, config.escape)) { p.topbar_col = null; // the active pane still has its body focus + p.header_column = null; + p.header_anchor = null; + p.header_drag = false; + p.header_scroll = 0; return; } if (hit(key, config.look_key) or hit(key, config.exec_key)) { - const word = wordAtCol(bar, cur); + const a = p.header_anchor orelse @as(u16, @intCast(cur)); + const word = if (a != cur) bar[@min(a, cur)..@max(a, cur)] else wordAtCol(bar, cur); p.topbar_col = null; - if (word.len > 0) _ = p.execute(p.active, word); + if (word.len > 0) { + if (!p.header_insert) { + // Preserve the workspace tag's keyboard command semantics. + _ = p.execute(p.active, word); + } else p.runBuiltin(if (hit(key, config.look_key)) config.look_cmd else config.exec_cmd, p.active, "", word); + } + return; + } + if (p.header_insert) { + if (key.ctrl and key.cp == 'v') { + p.emit(.read_clipboard); + return; + } + if (key.ctrl and (key.cp == 'c' or key.cp == 'x')) { + const anchor = p.header_anchor orelse @as(u16, @intCast(cur)); + if (anchor != cur) { + p.setClipboard(bar[@min(anchor, cur)..@max(anchor, cur)]); + if (key.cp == 'x') p.headerReplace(""); + } + return; + } + if (key.text.len > 0 and !key.ctrl and !key.alt) { + p.headerReplace(key.text); + return; + } + if (key.cp == Key.backspace or key.cp == Key.delete) { + if (p.header_anchor == null or p.header_anchor.? == cur) p.header_anchor = @intCast(if (key.cp == Key.backspace) modal.prevGrapheme(bar, cur) else modal.nextGrapheme(bar, cur)); + p.headerReplace(""); + return; + } + const next: ?usize = switch (key.cp) { + Key.left => modal.prevGrapheme(bar, cur), + Key.right => modal.nextGrapheme(bar, cur), + Key.home => 0, + Key.end => bar.len, + else => null, + }; + if (next) |at| { + if (key.shift) { + if (p.header_anchor == null) p.header_anchor = @intCast(cur); + } else p.header_anchor = null; + p.topbar_col = @intCast(at); + } + return; + } + if (hit(key, config.insert) or hit(key, config.append) or hit(key, config.insert_line_start) or hit(key, config.insert_line_end)) { + p.header_insert = true; + p.topbar_col = @intCast(if (hit(key, config.insert_line_end)) bar.len else if (hit(key, config.insert_line_start)) 0 else if (hit(key, config.append)) modal.nextGrapheme(bar, cur) else cur); return; } if (hit(key, config.topbar_down)) { + if (p.header_column == null and p.columnBarHeight() > 0) { + p.header_column = if (layout.findPane(p, p.active)) |f| f.col else 0; + p.header_scroll = 0; + p.topbar_col = 0; + return; + } var dest: ?usize = null; for (p.panes, 0..) |slot, i| { - if (slot == null or p.rects[i].y != TOPBAR_H) continue; + if (slot == null or p.rects[i].y != TOPBAR_H + p.columnBarHeight()) continue; + if (p.header_column) |c| if (p.rects[i].x != p.col_x[c]) continue; if (i == p.active) { dest = i; break; @@ -6582,11 +7394,18 @@ pub const Pardes = struct { const d = dest orelse return; const pane = p.panes[d].?; p.topbar_col = null; + p.header_column = null; p.active = d; p.enterTagEdit(pane, -1); if (pane.tag_edit) pane.mode = .normal; return; } + if (key.cp == 'k' or key.cp == Key.up) { + p.header_column = null; + p.header_anchor = null; + p.topbar_col = 0; + return; + } const col: ?usize = if (hit(key, config.topbar_left)) modal.prevGrapheme(bar, cur) else if (hit(key, config.topbar_right)) @@ -6861,6 +7680,19 @@ pub const Pardes = struct { return std.fmt.allocPrint(p.scratch.allocator(), "{s} {s}", .{ t, a }) catch txt; } + /// One mode transition for the terminal tag command and the Ctrl-B chord. + pub fn toggleTty(p: *Pardes, id: usize) void { + const pane = p.panes[id] orelse return; + if (!pane.isTerminal()) return; + p.active = id; + p.topbar_col = null; + if (pane.tag_edit) exitTagEdit(pane); + if (pane.mode == .tty) { + pane.mode = .normal; + pane.normal.clear(); + } else panes.Terminal.enterTty(p, id); + } + fn handleKey(p: *Pardes, key: Key) void { if (p.topbar_col != null) return p.topbarKey(key); const pane = p.panes[p.active] orelse return; @@ -6904,12 +7736,8 @@ pub const Pardes = struct { const tty_alt = hit(key, config.tty_toggle_alt); const tty_toggle = (key.ctrl and key.cp == p.opts.tty_toggle) or tty_alt; if (pane.isTerminal() and tty_toggle) { - if (pane.mode == .tty) { - if (tty_alt) return p.runBuiltin(.Last, p.active, "", null); - pane.mode = .normal; - pane.normal.clear(); - } else panes.Terminal.enterTty(p, p.active); - return; + if (pane.mode == .tty and tty_alt) return p.runBuiltin(.Last, p.active, "", null); + return p.toggleTty(p.active); } // A shell prompt is a pane you can leave: plain Esc there is Shift-Esc. if (pane.isTerminal() and pane.mode == .tty and hit(key, config.escape) and p.takesCommandLine(p.active)) @@ -8400,6 +9228,7 @@ pub const Pardes = struct { if (budget.* == 0) return null; budget.* -= 1; const ln = lines[@intCast(r)]; + if (pane.file) |f| if (!panes.Output.isWalkRow(f.output, ln)) continue; const on_start = r == start; switch (grain) { .word => { @@ -9640,7 +10469,7 @@ pub const Pardes = struct { pub const ChromeTarget = struct { col: u16, row: u16 }; pub fn chromeTarget(p: *const Pardes, col: u16, row: u16) ?ChromeTarget { - if (row < TOPBAR_H) return .{ .col = col, .row = row }; + if (row < TOPBAR_H + p.columnBarHeight()) return .{ .col = col, .row = row }; for (p.panes, 0..) |slot, id| { if (slot == null) continue; const rect = p.rects[id]; @@ -9703,7 +10532,8 @@ pub const Pardes = struct { const tag_y = if (p.settings.tag_bottom) r.y + r.h -| BOX_H else r.y; const body_y = if (p.settings.tag_bottom) r.y else r.y + BOX_H; const on_tag = row >= tag_y and row < tag_y + BOX_H; - const c: i32 = @as(i32, col) - @as(i32, r.x + config.GUTTER); + const c: i32 = @as(i32, col) - @as(i32, r.x + config.GUTTER) + + (if (on_tag) @as(i32, p.panes[id].?.tag_scroll) else 0); const v: i32 = if (on_tag) 0 else @as(i32, row) - @as(i32, body_y) + @as(i32, BOX_H); return .{ .sel = .{ .state = .dragging, .c0 = c, .c1 = c, .r0 = v, .r1 = v }, @@ -9854,7 +10684,66 @@ pub const Pardes = struct { p.chord_arg = null; } } - if (m.kind == .press) p.topbar_col = null; + if (p.header_drag and m.button == config.select_button and (m.kind == .drag or m.kind == .release)) { + const x = if (p.header_column) |c| p.col_x[c] else 0; + const bar = p.headerText(); + p.topbar_col = @intCast(@min(bar.len, panes.File.rawAtDisplay(bar, (mcol -| x) + p.header_scroll))); + if (m.kind == .release) p.header_drag = false; + return; + } + if (mrow < TOPBAR_H + p.columnBarHeight() and m.kind == .press and + (m.button == config.select_button or m.button == config.exec_button or m.button == config.look_button)) + { + var column: ?usize = null; + var x: u16 = 0; + if (mrow >= TOPBAR_H) { + for (0..p.ncol) |c| if (mcol >= p.col_x[c] and mcol < p.col_x[c] + p.col_w[c]) { + column = c; + x = p.col_x[c]; + if (layout.findPane(p, p.active)) |active_col| { + if (active_col.col != c and p.col_n[c] > 0) p.active = p.col_panes[c][0]; + } + break; + }; + if (column == null) return; + } + if (p.header_column != column) { + p.topbar_col = null; + p.header_anchor = null; + p.header_scroll = 0; + } + p.header_column = column; + const bar = p.headerText(); + const at = @min(bar.len, panes.File.rawAtDisplay(bar, (mcol -| x) + p.header_scroll)); + if (m.button == config.select_button) { + if (p.panes[p.active]) |pane| exitTagEdit(pane); + p.topbar_col = @intCast(at); + p.header_anchor = @intCast(at); + p.header_insert = true; + p.header_drag = true; + } else { + const selected = if (p.topbar_col) |cur| blk: { + const a = p.header_anchor orelse cur; + break :blk if (at >= @min(a, cur) and at < @max(a, cur)) bar[@min(a, cur)..@max(a, cur)] else wordAtCol(bar, at); + } else wordAtCol(bar, at); + const word = p.scratch.allocator().dupe(u8, selected) catch return; + p.topbar_col = null; + if (word.len > 0) { + if (m.button == config.exec_button) { + const named = std.meta.stringToEnum(Builtin, word); + const held = if (named) |b| (if (builtins.registry.takesArg(b)) p.heldSelection(p.active) else null) else p.heldSelection(p.active); + _ = p.execute(p.active, p.withArg(word, held)); + } else p.runBuiltin(config.look_cmd, p.active, "", word); + } + } + return; + } + if (m.kind == .press) { + p.topbar_col = null; + p.header_column = null; + p.header_anchor = null; + p.header_drag = false; + } switch (m.button) { .none => {}, // hover tracked above; motion has no other meaning @@ -9910,7 +10799,6 @@ pub const Pardes = struct { p.drag = .none; return; } - if (mrow < TOPBAR_H) return; // topbar: a select click is deliberately inert if (hovered) |mid| { const mr = p.rects[mid]; const mtag = if (p.settings.tag_bottom) mr.y + mr.h -| BOX_H else mr.y; @@ -9996,22 +10884,6 @@ pub const Pardes = struct { p.drag = .none; return; } - if (mrow < TOPBAR_H) { - // global tagbar: EXECUTE runs the command under the - // click (a stray select click must never Kill) - if (m.button == config.exec_button) { - var tb_buf: [1200]u8 = undefined; - const bar = p.topbar(&tb_buf); - const word = wordAtCol(bar, panes.File.rawAtDisplay(bar, mcol)); - const named = std.meta.stringToEnum(Builtin, word); - const held = if (named) |b| - (if (builtins.registry.takesArg(b)) p.heldSelection(p.active) else null) - else - p.heldSelection(p.active); // not a builtin: an ordinary acme 2-1 - if (word.len > 0) _ = p.execute(p.active, p.withArg(word, held)); - } - return; - } const id = hovered orelse return; const r = p.rects[id]; const pane = p.panes[id] orelse return; @@ -10100,7 +10972,8 @@ pub const Pardes = struct { 0 else @as(i32, @min(@max(mrow, r.y), r.y + body_h - 1)) - @as(i32, r.y) + @as(i32, BOX_H); - } else pane.sel[b].r1 = @as(i32, mrow) - @as(i32, r.y); + } else pane.sel[b].r1 = if (pane.sel[b].r0 < BOX_H) 0 else @as(i32, mrow) - @as(i32, r.y); + if (pane.sel[b].r1 < BOX_H) pane.sel[b].c1 += pane.tag_scroll; if (comptime pdf_enabled) panes.Pdf.pointerUpdate(&s.pdf, p, pane, mcol, mrow); } @@ -10109,7 +10982,7 @@ pub const Pardes = struct { const pane = p.panes[d.id] orelse return; const c = @as(i32, mcol) - @as(i32, p.rects[d.id].x + config.GUTTER); const text = p.tagText(p.scratch.allocator(), pane) catch return; - pane.tag_col = @intCast(@min(text.len, panes.File.rawAtDisplay(text, @intCast(@max(0, c))))); + pane.tag_col = @intCast(@min(text.len, panes.File.rawAtDisplay(text, @as(usize, @intCast(@max(0, c))) + pane.tag_scroll))); pane.tag_sel = pane.tag_col != pane.tag_anchor; }, .none => {}, @@ -10281,7 +11154,7 @@ pub const Pardes = struct { } } else { const clk = pane.sel[b]; - const operand = p.pointerOperand(pane, clk); + var operand = p.pointerOperand(pane, clk); if (operand.expanded) |expanded| pane.sel[b] = expanded else if (operand.text == null) @@ -10292,6 +11165,26 @@ pub const Pardes = struct { } const txt = operand.text; const cmd = if (s.button == config.look_button) config.look_cmd else config.exec_cmd; + if (clk.r0 < BOX_H and s.button == config.exec_button and pane.tag_name_len != null) { + const prefix_width = panes.File.displayWidth(p.tagPrefix(pane) catch return); + const on_name = @as(usize, @intCast(@max(0, clk.c0))) < prefix_width; + if (!p.commitTagName(pane)) return; + // Executing a staged filename commits it; executing its + // command tail uses the new identity (especially Save). + if (on_name) return; + const new_width = panes.File.displayWidth(p.tagPrefix(pane) catch return); + const shift = @as(i32, @intCast(new_width)) - @as(i32, @intCast(prefix_width)); + if (operand.expanded) |*sel| { + sel.c0 += shift; + sel.c1 += shift; + } + if (operand.preview) |*sel| { + sel.c0 += shift; + sel.c1 += shift; + } + pane.sel[b].c0 += shift; + pane.sel[b].c1 += shift; + } p.dispatchPointerBuiltin(s.id, cmd, txt, .{ .on_tag = clk.r0 < BOX_H, .operand = operand, @@ -10391,7 +11284,7 @@ pub const Pardes = struct { } else p.pasteText(pane, p.yank orelse return, true, 1); } - /// Newtty: a shell in the caller's directory, raw from the first frame, + /// Tty: a shell in the caller's directory, raw from the first frame, /// stacked below the caller like Alt-n's new shell. pub fn spawnTty(p: *Pardes, from: usize) void { const src = p.panes[from] orelse return; @@ -10528,19 +11421,6 @@ pub const Pardes = struct { } } - fn stackDocLeft(p: *Pardes, free: usize, nt: *Pane) void { - const lc = 0; - if (p.col_n[lc] > 0) if (p.panes[p.col_panes[lc][0]]) |top| { - layout.snapColWeights(p, lc); - const h = p.rects[p.col_panes[lc][0]].h; - nt.vweight = @floatFromInt(@max(1, h / 2)); - top.vweight = @floatFromInt(@max(1, h -| h / 2)); - }; - layout.insert(p, lc, 0, free); - p.active = free; - p.evictLonePristineTty(lc, free); - } - // ---- the ONE dispatcher: look (right/Enter) and execute (middle/Tab) ---- pub fn focusPaneLine(p: *Pardes, id: usize, at: look.Spot, landing: enum { center, keep }) void { @@ -10766,6 +11646,7 @@ pub const Pardes = struct { const previous_colors = p.settings.colors; const previous_transition = p.settings.panel_transition; const previous_tagline_percent = p.settings.font.tagline_percent; + const previous_column_tags = p.settings.column_tags; switch (setting.action) { .theme => { const want = std.mem.trim(u8, arg orelse return, " \t\r\n"); @@ -10776,10 +11657,10 @@ pub const Pardes = struct { }, .font => { if (comptime !font_picker) return; - const want = std.mem.trim(u8, arg orelse return, " \t\r\n"); - const matches = fonts.list(p.scratch.allocator(), want); + const spec = config.Runtime.FontSpec.parse(arg orelse return) orelse return; + const matches = fonts.list(p.scratch.allocator(), spec.name); if (matches.len == 0) return; - if (!p.settings.requestFont(matches[0].path, matches[0].name)) return; + if (!p.settings.requestFont(matches[0].path, matches[0].name, spec.size_hundredths)) return; p.font_request_taken = false; }, else => _ = p.settings.apply(setting, arg), @@ -10794,7 +11675,16 @@ pub const Pardes = struct { p.settings.panel_transition != previous_transition; const tagline_metrics_changed = setting.action == .tagline_size and p.settings.font.tagline_percent != previous_tagline_percent; - if (transition_changed or tagline_metrics_changed) { + if (p.settings.column_tags != previous_column_tags) { + if (p.header_column != null) { + p.topbar_col = null; + p.header_column = null; + p.header_anchor = null; + p.header_drag = false; + p.header_scroll = 0; + } + } + if (transition_changed or tagline_metrics_changed or p.settings.column_tags != previous_column_tags) { p.abandonPanelAnimations(); } } @@ -10829,20 +11719,19 @@ pub const Pardes = struct { p.active = free; return; }; - if (src_id) |sid| { - const src = layout.splitParent(p, sid); - const sf = layout.findPane(p, src).?; - layout.insert(p, sf.col, sf.idx + 1, free); - layout.splitBelow(p, src, nt); - p.active = free; - return; - } - if (doc and p.ncol < MAX_COLS and layout.columnFitsHalves(p, from_id, 100)) { - if (!layout.splitColumn(p, from_id, free, true)) return p.stackDocLeft(free, nt); + if (src_id == null and doc and p.ncol < MAX_COLS and + layout.columnFitsHalves(p, from_id, 100) and layout.splitColumn(p, from_id, free, true)) + { p.active = free; return; } - p.stackDocLeft(free, nt); + // Without room for a first-document column, split the Look origin + // just like Tty. Never displace (or evict) a top-left bystander. + const src = layout.splitParent(p, src_id orelse from_id); + const sf = layout.findPane(p, src).?; + layout.insert(p, sf.col, sf.idx + 1, free); + layout.splitBelow(p, src, nt); + p.active = free; } // ---- dump / load (acme-style: `pardes -l state.zon` restores a session) ---- @@ -10891,6 +11780,7 @@ pub const Pardes = struct { }; if (ids_len > 0) { columns[columns_len] = .{ + .tag = if (p.col_tags[c].custom) p.columnTag(c) else null, .weight = @as(f32, @floatFromInt(p.col_weight[c])) / @as(f32, @floatFromInt(column_weight_unit)), .panes = column_ids[c][0..ids_len], }; @@ -10904,6 +11794,7 @@ pub const Pardes = struct { .screen = .{ .cols = p.screen_w, .rows = p.screen_h }, .active = slot_to_pane[p.active] orelse 0, .topbar = config.topbar_str, + .topbar_custom = if (p.global_tag.custom) p.global_tag.slice("") else null, .theme = p.theme().name, .columns = columns[0..columns_len], .panes = dump_panes[0..panes_len], @@ -11033,9 +11924,12 @@ pub const Pardes = struct { }; p.restoreDumpTail(pane, src); pane.vweight = src.vweight; + pane.collapsed = src.collapsed; } p.ncol = @min(st.columns.len, MAX_COLS); + if (st.topbar_custom) |bar| _ = p.global_tag.set(bar); for (st.columns[0..p.ncol], 0..) |col, c| { + if (col.tag) |bar| _ = p.col_tags[c].set(bar); const scaled = @as(f64, @floatCast(col.weight)) * @as(f64, @floatFromInt(column_weight_unit)); const bounded = @min(scaled, @as(f64, @floatFromInt(max_column_weight))); p.col_weight[c] = @max(1, @as(u64, @intFromFloat(@round(bounded)))); @@ -11059,6 +11953,10 @@ pub const Pardes = struct { return; } const tail_class = tailClass(pane); + if (src.kind == .image and panes.Pdf.isPath(src.image.?.path)) { + if (panes.Pdf.legacySavedPrefix(src.image.?.path, src.tag)) |prefix| + return p.restoreTailAt(pane, src.tag, prefix, .generic); + } 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)) @@ -11109,13 +12007,15 @@ pub const Pardes = struct { const rest = saved_tag[pfx.len..]; const said = std.mem.trimStart(u8, rest, " "); const defaults: []const []const u8 = switch (class) { - .generic => &.{ pane_tail, prev_pane_tail, legacy_pane_tail }, - .file => &.{ file_pane_tail, prev_file_pane_tail, legacy_file_pane_tail }, + .generic => &.{ pdf_pane_tail, pane_tail, pre_collapse_pdf_pane_tail, pre_collapse_pane_tail, pre_column_pdf_pane_tail, pre_column_pane_tail, prev_pane_tail, legacy_pane_tail }, + .file => &.{ file_pane_tail, pre_collapse_file_pane_tail, pre_column_file_pane_tail, prev_file_pane_tail, legacy_file_pane_tail }, .output => &.{ - file_pane_tail, prev_file_pane_tail, legacy_file_pane_tail, - pane_tail, prev_pane_tail, legacy_pane_tail, + file_pane_tail, prev_file_pane_tail, legacy_file_pane_tail, + pane_tail, prev_pane_tail, legacy_pane_tail, + pre_column_file_pane_tail, pre_column_pane_tail, pre_collapse_file_pane_tail, + pre_collapse_pane_tail, }, - .terminal => &.{ terminal_pane_tail, prev_terminal_pane_tail, legacy_terminal_pane_tail, pane_tail, prev_pane_tail, legacy_pane_tail }, + .terminal => &.{ terminal_pane_tail, pre_collapse_terminal_pane_tail, pre_column_terminal_pane_tail, pre_tty_terminal_pane_tail, prev_terminal_pane_tail, legacy_terminal_pane_tail, pane_tail, pre_collapse_pane_tail, pre_column_pane_tail, prev_pane_tail, legacy_pane_tail }, }; for (defaults) |default| if (std.mem.eql(u8, said, std.mem.trimStart(u8, default, " "))) return; @@ -11231,12 +12131,21 @@ pub const Pardes = struct { /// Recompute geometry, push grid-size changes to each emulator + pty, fire /// deferred greetings. The mirror of the prototype's loop epilogue. fn sync(p: *Pardes) void { + if (p.header_column != null and p.columnBarHeight() == 0) { + p.topbar_col = null; + p.header_column = null; + p.header_anchor = null; + p.header_drag = false; + p.header_scroll = 0; + } p.reapPanes(); layout.compute(p); p.presentation.sync(p); p.trackJump(); for (&p.panes, 0..) |*slot, id| { const pane = slot.* orelse continue; + if (pane.tag_name_len != null and (id != p.active or p.topbar_col != null)) + exitTagEdit(pane); if (comptime terminal_panes) if (pane.terminal) |state| { var off: u16 = 0; while (off < state.reply_len) { @@ -11359,8 +12268,8 @@ pub const Pardes = struct { s.cell_diffs = &.{}; const chrome = p.chromeTheme(); const th = p.theme(); // the message row paints in the editor's colours - // gaps between panes read as chrome (scrollbar track), not raw default - s.fill(0, 0, s.cols, s.rows, .{ .bg = .{ .rgb = chrome.scroll_track } }); + // Separators have their own role; a scrollbar need not frame the page. + s.fill(0, 0, s.cols, s.rows, .{ .bg = .{ .rgb = chrome.border } }); for (&p.panes, 0..) |*slot, id| { const pane = slot.* orelse continue; @@ -11411,21 +12320,24 @@ pub const Pardes = struct { } // global tagbar: full width, top row + p.revealHeader(); s.fill(0, 0, s.cols, TOPBAR_H, .{ .bg = .{ .rgb = chrome.tag_bg }, .font_role = .tagline, }); var tb_buf: [1200]u8 = undefined; - _ = s.print(0, 0, s.cols, p.topbar(&tb_buf), .{ + const global_bar = p.topbar(&tb_buf); + const global_scroll = if (p.topbar_col != null and p.header_column == null) p.header_scroll else 0; + _ = s.print(0, 0, s.cols, global_bar[panes.File.rawAtDisplay(global_bar, global_scroll)..], .{ .fg = .{ .rgb = chrome.tag_fg }, .bg = .{ .rgb = chrome.tag_bg }, .font_role = .tagline, }); if (p.pointer_inside and p.hover_row < TOPBAR_H) { const bar = p.topbar(&tb_buf); - if (wordBoundsAtCol(bar, panes.File.rawAtDisplay(bar, p.hover_col))) |bounds| { - var col = panes.File.rawDisplayCol(bar, bounds.lo); - const hi = panes.File.rawDisplayCol(bar, bounds.hi); + if (wordBoundsAtCol(bar, panes.File.rawAtDisplay(bar, p.hover_col + global_scroll))) |bounds| { + var col = panes.File.rawDisplayCol(bar, bounds.lo) -| global_scroll; + const hi = panes.File.rawDisplayCol(bar, bounds.hi) -| global_scroll; while (col < hi and col < s.cols) : (col += 1) { const cell = s.at(@intCast(col), 0); cell.default = false; @@ -11434,9 +12346,53 @@ pub const Pardes = struct { } } } - if (p.topbar_col) |c| { - const col = panes.File.rawDisplayCol(p.topbar(&tb_buf), c); - if (col < s.cols) s.cursor = .{ .x = @intCast(col), .y = 0, .bar = false }; + if (p.topbar_col) |c| if (p.header_column == null) { + const col = panes.File.rawDisplayCol(p.topbar(&tb_buf), c) -| global_scroll; + if (col < s.cols) s.cursor = .{ .x = @intCast(col), .y = 0, .bar = p.header_insert }; + }; + if (p.columnBarHeight() > 0) for (0..p.ncol) |c| { + const x = p.col_x[c]; + const w = p.col_w[c]; + if (w == 0 or x >= s.cols) continue; + const active_col = if (layout.findPane(p, p.active)) |f| f.col == c else false; + const bg = if (active_col and p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg; + const fg = if (active_col and p.settings.focus_tint) chrome.tag_active_fg else chrome.tag_fg; + s.fill(x, TOPBAR_H, w, 1, .{ .bg = .{ .rgb = bg }, .font_role = .tagline }); + const bar_scroll = if (p.topbar_col != null and p.header_column == c) p.header_scroll else 0; + const tag = p.columnTag(c); + _ = s.print(x, TOPBAR_H, w, tag[panes.File.rawAtDisplay(tag, bar_scroll)..], .{ + .fg = .{ .rgb = fg }, + .bg = .{ .rgb = bg }, + .font_role = .tagline, + }); + if (p.pointer_inside and p.hover_row == TOPBAR_H and p.hover_col >= x and p.hover_col < x + w) { + const bar = p.columnTag(c); + if (wordBoundsAtCol(bar, panes.File.rawAtDisplay(bar, p.hover_col - x + bar_scroll))) |bounds| { + var col = panes.File.rawDisplayCol(bar, bounds.lo) -| bar_scroll; + const hi = panes.File.rawDisplayCol(bar, bounds.hi) -| bar_scroll; + while (col < hi and col < w) : (col += 1) { + const cell = s.at(x + @as(u16, @intCast(col)), TOPBAR_H); + cell.style.bg = .{ .rgb = th.sel_bg }; + cell.style.fg = .{ .rgb = th.sel_fg }; + } + } + } + }; + if (p.topbar_col) |cur| { + const bar = p.headerText(); + const x = if (p.header_column) |c| p.col_x[c] else 0; + const w = if (p.header_column) |c| p.col_w[c] else s.cols; + const y: u16 = if (p.header_column != null) TOPBAR_H else 0; + const anchor = p.header_anchor orelse cur; + var col = panes.File.rawDisplayCol(bar, @min(cur, anchor)) -| p.header_scroll; + const hi = panes.File.rawDisplayCol(bar, @max(cur, anchor)) -| p.header_scroll; + while (col < hi and col < w) : (col += 1) { + const cell = s.at(x + @as(u16, @intCast(col)), y); + cell.style.fg = .{ .rgb = th.sel_fg }; + cell.style.bg = .{ .rgb = th.sel_bg }; + } + const caret = panes.File.rawDisplayCol(bar, @min(cur, bar.len)) -| p.header_scroll; + if (caret < w) s.cursor = .{ .x = x + @as(u16, @intCast(caret)), .y = y, .bar = p.header_insert }; } switch (p.drag) { @@ -11665,8 +12621,9 @@ pub const Pardes = struct { const sy = if (row < BOX_H) tag_y else body_y + row - BOX_H; // File line numbers occupy a gutter only in the body. The tag is // row zero in Sel space and starts at its real first text cell. - var col: i32 = if (pane.file != null and row >= BOX_H) @max(c0, @as(i32, panes.File.gutterWidth(pane))) else c0; - while (col <= c1 and col < tw) : (col += 1) { + const scroll: i32 = if (row < BOX_H) pane.tag_scroll else 0; + var col: i32 = if (pane.file != null and row >= BOX_H) @max(c0, @as(i32, panes.File.gutterWidth(pane))) else @max(0, c0 - scroll); + while (col <= c1 - scroll and col < tw) : (col += 1) { const cell = s.at(tx + @as(u16, @intCast(col)), sy); cell.default = false; cell.style.bg = .{ .rgb = bg }; @@ -11717,21 +12674,37 @@ pub const Pardes = struct { .tty => config.tag_tty, }, box_style); - // tag: live prefix (cwd/path) + editable tail, on a dim bar - const tag_style: CellStyle = .{ .fg = .{ .rgb = chrome.tag_fg }, .bg = .{ .rgb = chrome.tag_bg }, .font_role = .tagline }; + // Focus is visible along the tag without changing its live text grid. + const tag_bg = if (active and p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg; + const tag_fg = if (active and p.settings.focus_tint) chrome.tag_active_fg else chrome.tag_fg; + const tag_style: CellStyle = .{ .fg = .{ .rgb = tag_fg }, .bg = .{ .rgb = tag_bg }, .font_role = .tagline }; // Padding belongs to the same font row but keeps its default ink: a // role must not manufacture a foreground color for blank cells. s.fill(tx, tag_y, tw, BOX_H, .{ - .bg = .{ .rgb = chrome.tag_bg }, + .bg = .{ .rgb = tag_bg }, .font_role = .tagline, }); const tag = try p.tagText(arena, pane); const prompt_at = pane.promptAt(); + if (active and pane.tag_edit and prompt_at == null) { + const caret = panes.File.rawDisplayCol(tag, pane.tag_col); + var scroll: usize = pane.tag_scroll; + if (caret < scroll) scroll = caret; + if (caret >= scroll + tw) scroll = caret - tw + 1; + var raw = panes.File.rawAtDisplay(tag, scroll); + scroll = panes.File.rawDisplayCol(tag, raw); + if (caret >= scroll + tw) { + raw = modal.nextGrapheme(tag, raw); + scroll = panes.File.rawDisplayCol(tag, raw); + } + pane.tag_scroll = @intCast(scroll); + } else if (!pane.tag_edit or prompt_at != null) pane.tag_scroll = 0; const tag_cut = if (prompt_at) |at| @min(tag.len, tag.len - @min(tag.len, pane.tag_tail_len) + at) else tag.len; - _ = s.print(tx, tag_y, tw, tag[0..tag_cut], tag_style); + const tag_start = @min(tag_cut, panes.File.rawAtDisplay(tag, pane.tag_scroll)); + _ = s.print(tx, tag_y, tw, tag[tag_start..tag_cut], tag_style); if (p.look_hover_preview) |preview| { if (preview.pane == id and preview.serial == pane.serial) if (preview.sel) |sel| if (@min(sel.r0, sel.r1) < BOX_H) { const preview_bg = mix(page_rgb, mix(page_rgb, th.sel_bg)); @@ -11740,10 +12713,10 @@ pub const Pardes = struct { } if (pane.tag_edit and pane.tag_sel) { const b = tagSelBounds(pane); - var col = panes.File.rawDisplayCol(tag, b.lo); + var col = panes.File.rawDisplayCol(tag, b.lo) -| pane.tag_scroll; const hi = modal.nextGrapheme(tag, b.hi); - const end = panes.File.rawDisplayCol(tag, hi) -| 1; - while (col <= end and col < tw) : (col += 1) { + const end = panes.File.rawDisplayCol(tag, hi); + while (col + pane.tag_scroll < end and col < tw) : (col += 1) { const cell = s.at(tx + @as(u16, @intCast(col)), tag_y); cell.default = false; cell.style.bg = .{ .rgb = th.sel_bg }; @@ -11753,10 +12726,14 @@ pub const Pardes = struct { // cursor while editing the tag: byte offset mapped to its display cell if (active and pane.tag_edit) { // bar while typing, block for `:` normal mode (same rule as a body) - const col = panes.File.rawDisplayCol(tag, pane.tag_col); + const col = panes.File.rawDisplayCol(tag, pane.tag_col) -| pane.tag_scroll; if (col < tw) s.cursor = .{ .x = tx + @as(u16, @intCast(col)), .y = tag_y, .bar = pane.mode == .insert }; } + // Folded panes retain their body state, but neither body pixels nor + // its cursor belong outside the one-row tag rectangle. + if (pane.collapsed) return; + if (comptime pdf_enabled) if (pane.hasPdf() and panes.Pdf.draw(p, pane, r, id, tx, tw)) return; diff --git a/src/tag_line.zig b/src/tag_line.zig new file mode 100644 index 00000000..d1a9d166 --- /dev/null +++ b/src/tag_line.zig @@ -0,0 +1,41 @@ +//! Bounded, single-line editable chrome. Offsets are UTF-8 byte offsets. +const std = @import("std"); +const modal = @import("modal.zig"); +pub const Line = struct { + text: [@import("memory.zig").limits.max_tag_tail]u8 = undefined, + len: usize = 0, + custom: bool = false, + + pub fn slice(self: *const Line, fallback: []const u8) []const u8 { + return if (self.custom) self.text[0..self.len] else fallback; + } + pub fn set(self: *Line, bytes: []const u8) bool { + if (bytes.len > self.text.len or std.mem.indexOfAny(u8, bytes, "\r\n\x00") != null or !std.unicode.utf8ValidateSlice(bytes)) return false; + @memcpy(self.text[0..bytes.len], bytes); + self.len = bytes.len; + self.custom = true; + return true; + } + pub fn replace(self: *Line, fallback: []const u8, lo: usize, hi: usize, bytes: []const u8) bool { + const current = self.slice(fallback); + if (lo > hi or hi > current.len) return false; + var next: [@import("memory.zig").limits.max_tag_tail]u8 = undefined; + const size = lo + bytes.len + current.len - hi; + if (size > next.len) return false; + @memcpy(next[0..lo], current[0..lo]); + @memcpy(next[lo..][0..bytes.len], bytes); + @memcpy(next[lo + bytes.len ..][0 .. current.len - hi], current[hi..]); + return self.set(next[0..size]); + } +}; + +test "tag replacement is atomic and keeps UTF-8 intact" { + var line: Line = .{}; + try std.testing.expect(line.replace("New Find", 4, 8, "Grep")); + try std.testing.expectEqualStrings("New Grep", line.slice("")); + try std.testing.expect(!line.replace("", 0, 0, "bad\nline")); + try std.testing.expectEqualStrings("New Grep", line.slice("")); + try std.testing.expect(line.set("é界")); + try std.testing.expect(line.replace("", 0, modal.nextGrapheme(line.slice(""), 0), "")); + try std.testing.expectEqualStrings("界", line.slice("")); +} diff --git a/src/themes/atelier.zig b/src/themes/atelier.zig new file mode 100644 index 00000000..8b45623b --- /dev/null +++ b/src/themes/atelier.zig @@ -0,0 +1,49 @@ +//! Pardes atelier: An Acme homage: butter-yellow paper, blue-green tags and garden ink. +//! Original palette; editor chrome, search, diagnostics and ANSI output are +//! designed together. See docs/themes.md for the shared role contract. +pub const theme = .{ + .name = "atelier", + .bg = .{ 0xff, 0xfd, 0xeb }, + .fg = .{ 0x29, 0x3f, 0x35 }, + .tag_bg = .{ 0xe4, 0xf0, 0xed }, + .tag_fg = .{ 0x3d, 0x60, 0x55 }, + .tag_active_bg = .{ 0xd3, 0xe7, 0xdf }, + .tag_active_fg = .{ 0x35, 0x58, 0x4d }, + .box = .{ 0x3e, 0x7a, 0x68 }, + .box_dim = .{ 0x99, 0xb6, 0xa3 }, + .kw = .{ 0x76, 0x4d, 0x80 }, + .str = .{ 0x3c, 0x6c, 0x43 }, + .num = .{ 0x88, 0x57, 0x22 }, + .comment = .{ 0x35, 0x73, 0x3a }, + .lineno = .{ 0x90, 0x98, 0x80 }, + .lineno_active = .{ 0x88, 0x90, 0x78 }, + .scroll_track = .{ 0xee, 0xec, 0xcf }, + .scroll_thumb = .{ 0x9d, 0xa9, 0x7b }, + .border = .{ 0xb8, 0xc8, 0xb4 }, + .sel_bg = .{ 0xe5, 0xe9, 0xae }, + .sel_fg = .{ 0x35, 0x49, 0x2d }, + .search_bg = .{ 0xf0, 0xd7, 0x94 }, + .search_fg = .{ 0x61, 0x47, 0x15 }, + .diagnostic_error = .{ 0xa3, 0x3d, 0x37 }, + .diagnostic_warning = .{ 0x81, 0x59, 0x08 }, + .diagnostic_info = .{ 0x33, 0x65, 0x78 }, + .diagnostic_hint = .{ 0x52, 0x6e, 0x3d }, + .palette = .{ + .{ 0x29, 0x3f, 0x35 }, + .{ 0xa3, 0x3d, 0x37 }, + .{ 0x3c, 0x6c, 0x43 }, + .{ 0x81, 0x59, 0x08 }, + .{ 0x33, 0x65, 0x78 }, + .{ 0x76, 0x4d, 0x80 }, + .{ 0x52, 0x6e, 0x3d }, + .{ 0x29, 0x3f, 0x35 }, + .{ 0x68, 0x76, 0x58 }, + .{ 0xa3, 0x3d, 0x37 }, + .{ 0x3c, 0x6c, 0x43 }, + .{ 0x88, 0x57, 0x22 }, + .{ 0x33, 0x65, 0x78 }, + .{ 0x76, 0x4d, 0x80 }, + .{ 0x52, 0x6e, 0x3d }, + .{ 0x29, 0x3f, 0x35 }, + }, +}; diff --git a/src/themes/clay.zig b/src/themes/clay.zig new file mode 100644 index 00000000..bf9e7027 --- /dev/null +++ b/src/themes/clay.zig @@ -0,0 +1,50 @@ +//! Pardes clay: Gruvbox warm charcoal and earthy brights; deep olive command rails. +//! A classic-inspired adaptation, not an exact upstream port. +//! Reference: vendor/themes/gruvbox.json (Gruvbox Dark). +//! Pardes-specific tag, search, diagnostic and ANSI roles; see docs/themes.md. +pub const theme = .{ + .name = "clay", + .bg = .{ 0x10, 0x10, 0x0e }, + .fg = .{ 0xf3, 0xe5, 0xbc }, + .tag_bg = .{ 0x34, 0x3f, 0x36 }, + .tag_fg = .{ 0xd7, 0xd6, 0xb6 }, + .tag_active_bg = .{ 0x42, 0x4f, 0x40 }, + .tag_active_fg = .{ 0xe3, 0xe2, 0xc2 }, + .box = .{ 0x8e, 0xc0, 0x7c }, + .box_dim = .{ 0x67, 0x77, 0x52 }, + .kw = .{ 0xfb, 0x8a, 0x69 }, + .str = .{ 0xb8, 0xbb, 0x26 }, + .num = .{ 0xd3, 0x86, 0x9b }, + .comment = .{ 0xe2, 0xb5, 0x6f }, + .lineno = .{ 0x69, 0x64, 0x55 }, + .lineno_active = .{ 0x71, 0x6c, 0x5d }, + .scroll_track = .{ 0x22, 0x21, 0x19 }, + .scroll_thumb = .{ 0x8a, 0x90, 0x6b }, + .border = .{ 0x56, 0x64, 0x48 }, + .sel_bg = .{ 0x4f, 0x58, 0x3c }, + .sel_fg = .{ 0xfb, 0xf1, 0xc7 }, + .search_bg = .{ 0x66, 0x51, 0x28 }, + .search_fg = .{ 0xff, 0xe2, 0xa0 }, + .diagnostic_error = .{ 0xfb, 0x8a, 0x69 }, + .diagnostic_warning = .{ 0xfa, 0xbd, 0x2f }, + .diagnostic_info = .{ 0x83, 0xb5, 0xa8 }, + .diagnostic_hint = .{ 0x8e, 0xc0, 0x7c }, + .palette = .{ + .{ 0x10, 0x10, 0x0e }, + .{ 0xfb, 0x8a, 0x69 }, + .{ 0xb8, 0xbb, 0x26 }, + .{ 0xfa, 0xbd, 0x2f }, + .{ 0x8c, 0xb0, 0xcb }, + .{ 0xd3, 0x86, 0x9b }, + .{ 0x8e, 0xc0, 0xb3 }, + .{ 0xf3, 0xe5, 0xbc }, + .{ 0xb1, 0xa1, 0x8b }, + .{ 0xfb, 0x8a, 0x69 }, + .{ 0xb8, 0xbb, 0x26 }, + .{ 0xfa, 0xbd, 0x2f }, + .{ 0x8c, 0xb0, 0xcb }, + .{ 0xd3, 0x86, 0x9b }, + .{ 0x8e, 0xc0, 0xb3 }, + .{ 0xf3, 0xe5, 0xbc }, + }, +}; diff --git a/src/themes/daybreak.zig b/src/themes/daybreak.zig new file mode 100644 index 00000000..28c0ac0a --- /dev/null +++ b/src/themes/daybreak.zig @@ -0,0 +1,49 @@ +//! Pardes daybreak: Crisp white, deep blue ink, and high-contrast navigational cues. +//! Original palette; editor chrome, search, diagnostics and ANSI output are +//! designed together. See docs/themes.md for the shared role contract. +pub const theme = .{ + .name = "daybreak", + .bg = .{ 0xff, 0xff, 0xff }, + .fg = .{ 0x14, 0x2d, 0x40 }, + .tag_bg = .{ 0xed, 0xf2, 0xf7 }, + .tag_fg = .{ 0x29, 0x46, 0x5e }, + .tag_active_bg = .{ 0xdf, 0xea, 0xf3 }, + .tag_active_fg = .{ 0x21, 0x3e, 0x56 }, + .box = .{ 0x07, 0x5f, 0x91 }, + .box_dim = .{ 0x81, 0x9d, 0xb1 }, + .kw = .{ 0x62, 0x2a, 0x88 }, + .str = .{ 0x24, 0x5b, 0x30 }, + .num = .{ 0x76, 0x42, 0x09 }, + .comment = .{ 0x14, 0x56, 0x38 }, + .lineno = .{ 0x88, 0x93, 0x9b }, + .lineno_active = .{ 0x80, 0x8b, 0x93 }, + .scroll_track = .{ 0xe6, 0xed, 0xf3 }, + .scroll_thumb = .{ 0x6b, 0x85, 0x9b }, + .border = .{ 0x85, 0x9b, 0xac }, + .sel_bg = .{ 0xc1, 0xde, 0xf3 }, + .sel_fg = .{ 0x10, 0x36, 0x4f }, + .search_bg = .{ 0xff, 0xe3, 0x96 }, + .search_fg = .{ 0x4b, 0x35, 0x00 }, + .diagnostic_error = .{ 0xa1, 0x1c, 0x30 }, + .diagnostic_warning = .{ 0x70, 0x49, 0x00 }, + .diagnostic_info = .{ 0x07, 0x5a, 0x83 }, + .diagnostic_hint = .{ 0x38, 0x55, 0x2b }, + .palette = .{ + .{ 0x14, 0x2d, 0x40 }, + .{ 0xa1, 0x1c, 0x30 }, + .{ 0x24, 0x5b, 0x30 }, + .{ 0x70, 0x49, 0x00 }, + .{ 0x07, 0x5a, 0x83 }, + .{ 0x62, 0x2a, 0x88 }, + .{ 0x38, 0x55, 0x2b }, + .{ 0x14, 0x2d, 0x40 }, + .{ 0x45, 0x56, 0x67 }, + .{ 0xa1, 0x1c, 0x30 }, + .{ 0x24, 0x5b, 0x30 }, + .{ 0x76, 0x42, 0x09 }, + .{ 0x07, 0x5a, 0x83 }, + .{ 0x62, 0x2a, 0x88 }, + .{ 0x38, 0x55, 0x2b }, + .{ 0x14, 0x2d, 0x40 }, + }, +}; diff --git a/src/themes/dusk.zig b/src/themes/dusk.zig new file mode 100644 index 00000000..e4ba012b --- /dev/null +++ b/src/themes/dusk.zig @@ -0,0 +1,49 @@ +//! Pardes dusk: Soft plum charcoal, copper comments and deliberately gentle text. +//! Original palette; editor chrome, search, diagnostics and ANSI output are +//! designed together. See docs/themes.md for the shared role contract. +pub const theme = .{ + .name = "dusk", + .bg = .{ 0x39, 0x32, 0x3b }, + .fg = .{ 0xc3, 0xb6, 0xc1 }, + .tag_bg = .{ 0x2c, 0x26, 0x30 }, + .tag_fg = .{ 0xc6, 0xb6, 0xc5 }, + .tag_active_bg = .{ 0x3b, 0x2f, 0x3b }, + .tag_active_fg = .{ 0xd2, 0xc2, 0xd1 }, + .box = .{ 0xe4, 0xb3, 0x9b }, + .box_dim = .{ 0x71, 0x57, 0x68 }, + .kw = .{ 0xcf, 0xb1, 0xe8 }, + .str = .{ 0xba, 0xc8, 0x94 }, + .num = .{ 0xe8, 0xb5, 0x92 }, + .comment = .{ 0xf1, 0xb9, 0x89 }, + .lineno = .{ 0x82, 0x71, 0x7e }, + .lineno_active = .{ 0x8a, 0x79, 0x86 }, + .scroll_track = .{ 0x3d, 0x35, 0x40 }, + .scroll_thumb = .{ 0x8b, 0x69, 0x7c }, + .border = .{ 0x51, 0x41, 0x4f }, + .sel_bg = .{ 0x61, 0x44, 0x53 }, + .sel_fg = .{ 0xff, 0xf0, 0xeb }, + .search_bg = .{ 0x5e, 0x49, 0x2e }, + .search_fg = .{ 0xff, 0xe3, 0xa2 }, + .diagnostic_error = .{ 0xf6, 0xaa, 0xa4 }, + .diagnostic_warning = .{ 0xed, 0xca, 0x8a }, + .diagnostic_info = .{ 0xaa, 0xcb, 0xdc }, + .diagnostic_hint = .{ 0xc2, 0xcd, 0xa1 }, + .palette = .{ + .{ 0x39, 0x32, 0x3b }, + .{ 0xf6, 0xaa, 0xa4 }, + .{ 0xba, 0xc8, 0x94 }, + .{ 0xed, 0xca, 0x8a }, + .{ 0xaa, 0xcb, 0xdc }, + .{ 0xcf, 0xb1, 0xe8 }, + .{ 0xc2, 0xcd, 0xa1 }, + .{ 0xc3, 0xb6, 0xc1 }, + .{ 0xab, 0x9e, 0xab }, + .{ 0xf6, 0xaa, 0xa4 }, + .{ 0xba, 0xc8, 0x94 }, + .{ 0xe8, 0xb5, 0x92 }, + .{ 0xaa, 0xcb, 0xdc }, + .{ 0xcf, 0xb1, 0xe8 }, + .{ 0xc2, 0xcd, 0xa1 }, + .{ 0xc3, 0xb6, 0xc1 }, + }, +}; diff --git a/src/themes/forge.zig b/src/themes/forge.zig new file mode 100644 index 00000000..ee23640d --- /dev/null +++ b/src/themes/forge.zig @@ -0,0 +1,51 @@ +//! Pardes forge: Dark Plus blue and peach on near-black coal; slate-blue command rails. +//! A classic-inspired adaptation, not an exact upstream port. +//! Reference: vendor/themes/dark_plus.toml. +//! Pardes-specific tag, search, diagnostic and ANSI roles; see docs/themes.md. +pub const theme = .{ + .name = "forge", + .bg = .{ 0x0c, 0x0c, 0x0e }, + .fg = .{ 0xe5, 0xe5, 0xe5 }, + .tag_bg = .{ 0x22, 0x25, 0x2b }, + .tag_fg = .{ 0xc5, 0xc9, 0xd2 }, + .tag_active_bg = .{ 0x2d, 0x34, 0x40 }, + .tag_active_fg = .{ 0xd1, 0xd5, 0xde }, + .box = .{ 0xa5, 0xc4, 0xee }, + .box_dim = .{ 0x59, 0x65, 0x77 }, + .kw = .{ 0x75, 0xb5, 0xe8 }, + .str = .{ 0xce, 0x91, 0x78 }, + .num = .{ 0xb5, 0xce, 0xa8 }, + .comment = .{ 0xb3, 0xca, 0xff }, + .lineno = .{ 0x62, 0x65, 0x6c }, + .lineno_active = .{ 0x6a, 0x6d, 0x74 }, + .scroll_track = .{ 0x1a, 0x1c, 0x21 }, + .scroll_thumb = .{ 0x73, 0x80, 0x92 }, + .border = .{ 0x47, 0x52, 0x63 }, + .sel_bg = .{ 0x26, 0x4f, 0x68 }, + .sel_fg = .{ 0xe2, 0xee, 0xf4 }, + .search_bg = .{ 0x59, 0x4a, 0x25 }, + .search_fg = .{ 0xf5, 0xdf, 0xa1 }, + .diagnostic_error = .{ 0xf2, 0x8a, 0x81 }, + .diagnostic_warning = .{ 0xd7, 0xba, 0x7d }, + .diagnostic_info = .{ 0x75, 0xbe, 0xff }, + .diagnostic_hint = .{ 0x8b, 0xce, 0xba }, + // ANSI roles are semantic colors, independent of peach string syntax. + .palette = .{ + .{ 0x0c, 0x0c, 0x0e }, + .{ 0xf2, 0x8a, 0x81 }, + .{ 0xb5, 0xce, 0xa8 }, + .{ 0xd7, 0xba, 0x7d }, + .{ 0x75, 0xbe, 0xff }, + .{ 0xc5, 0x86, 0xc0 }, + .{ 0x8b, 0xce, 0xba }, + .{ 0xe5, 0xe5, 0xe5 }, + .{ 0x9c, 0xa0, 0xa9 }, + .{ 0xf2, 0x8a, 0x81 }, + .{ 0xb5, 0xce, 0xa8 }, + .{ 0xd7, 0xba, 0x7d }, + .{ 0x75, 0xbe, 0xff }, + .{ 0xc5, 0x86, 0xc0 }, + .{ 0x8b, 0xce, 0xba }, + .{ 0xe5, 0xe5, 0xe5 }, + }, +}; diff --git a/src/themes/forge_black.zig b/src/themes/forge_black.zig new file mode 100644 index 00000000..d9605e97 --- /dev/null +++ b/src/themes/forge_black.zig @@ -0,0 +1,51 @@ +//! Pardes forge_black: Dark Plus blue and peach on pure black, with cool slate tags. +//! A classic-inspired adaptation, not an exact upstream port. +//! Reference: vendor/themes/dark_plus.toml. +//! Pardes-specific tag, search, diagnostic and ANSI roles; see docs/themes.md. +pub const theme = .{ + .name = "forge_black", + .bg = .{ 0x00, 0x00, 0x00 }, + .fg = .{ 0xf6, 0xf6, 0xf6 }, + .tag_bg = .{ 0x22, 0x25, 0x2b }, + .tag_fg = .{ 0xc5, 0xc9, 0xd2 }, + .tag_active_bg = .{ 0x2d, 0x34, 0x40 }, + .tag_active_fg = .{ 0xd1, 0xd5, 0xde }, + .box = .{ 0xa5, 0xc4, 0xee }, + .box_dim = .{ 0x59, 0x65, 0x77 }, + .kw = .{ 0x75, 0xb5, 0xe8 }, + .str = .{ 0xce, 0x91, 0x78 }, + .num = .{ 0xb5, 0xce, 0xa8 }, + .comment = .{ 0xb3, 0xca, 0xff }, + .lineno = .{ 0x62, 0x65, 0x6c }, + .lineno_active = .{ 0x6a, 0x6d, 0x74 }, + .scroll_track = .{ 0x14, 0x16, 0x1a }, + .scroll_thumb = .{ 0x73, 0x80, 0x92 }, + .border = .{ 0x47, 0x52, 0x63 }, + .sel_bg = .{ 0x26, 0x4f, 0x68 }, + .sel_fg = .{ 0xe2, 0xee, 0xf4 }, + .search_bg = .{ 0x59, 0x4a, 0x25 }, + .search_fg = .{ 0xf5, 0xdf, 0xa1 }, + .diagnostic_error = .{ 0xf2, 0x8a, 0x81 }, + .diagnostic_warning = .{ 0xd7, 0xba, 0x7d }, + .diagnostic_info = .{ 0x75, 0xbe, 0xff }, + .diagnostic_hint = .{ 0x8b, 0xce, 0xba }, + // ANSI roles are semantic colors, independent of peach string syntax. + .palette = .{ + .{ 0x00, 0x00, 0x00 }, + .{ 0xf2, 0x8a, 0x81 }, + .{ 0xb5, 0xce, 0xa8 }, + .{ 0xd7, 0xba, 0x7d }, + .{ 0x75, 0xbe, 0xff }, + .{ 0xc5, 0x86, 0xc0 }, + .{ 0x8b, 0xce, 0xba }, + .{ 0xf6, 0xf6, 0xf6 }, + .{ 0x9c, 0xa0, 0xa9 }, + .{ 0xf2, 0x8a, 0x81 }, + .{ 0xb5, 0xce, 0xa8 }, + .{ 0xd7, 0xba, 0x7d }, + .{ 0x75, 0xbe, 0xff }, + .{ 0xc5, 0x86, 0xc0 }, + .{ 0x8b, 0xce, 0xba }, + .{ 0xf6, 0xf6, 0xf6 }, + }, +}; diff --git a/src/themes/forge_soft.zig b/src/themes/forge_soft.zig new file mode 100644 index 00000000..0d882fbb --- /dev/null +++ b/src/themes/forge_soft.zig @@ -0,0 +1,51 @@ +//! Pardes forge_soft: Dark Plus on neutral slate, with softer text and vivid blue comments. +//! A classic-inspired adaptation, not an exact upstream port. +//! Reference: vendor/themes/dark_plus.toml. +//! Pardes-specific tag, search, diagnostic and ANSI roles; see docs/themes.md. +pub const theme = .{ + .name = "forge_soft", + .bg = .{ 0x35, 0x38, 0x3e }, + .fg = .{ 0xbf, 0xc3, 0xcc }, + .tag_bg = .{ 0x2a, 0x2d, 0x34 }, + .tag_fg = .{ 0xc5, 0xc9, 0xd2 }, + .tag_active_bg = .{ 0x37, 0x3e, 0x49 }, + .tag_active_fg = .{ 0xd1, 0xd5, 0xde }, + .box = .{ 0xa5, 0xc4, 0xee }, + .box_dim = .{ 0x59, 0x65, 0x77 }, + .kw = .{ 0x75, 0xb5, 0xe8 }, + .str = .{ 0xda, 0xa5, 0x8e }, + .num = .{ 0xb5, 0xce, 0xa8 }, + .comment = .{ 0xb3, 0xca, 0xff }, + .lineno = .{ 0x86, 0x89, 0x91 }, + .lineno_active = .{ 0x8e, 0x91, 0x99 }, + .scroll_track = .{ 0x3d, 0x40, 0x47 }, + .scroll_thumb = .{ 0x73, 0x80, 0x92 }, + .border = .{ 0x47, 0x52, 0x63 }, + .sel_bg = .{ 0x26, 0x4f, 0x68 }, + .sel_fg = .{ 0xe2, 0xee, 0xf4 }, + .search_bg = .{ 0x59, 0x4a, 0x25 }, + .search_fg = .{ 0xf5, 0xdf, 0xa1 }, + .diagnostic_error = .{ 0xf2, 0x8a, 0x81 }, + .diagnostic_warning = .{ 0xd7, 0xba, 0x7d }, + .diagnostic_info = .{ 0x75, 0xbe, 0xff }, + .diagnostic_hint = .{ 0x8b, 0xce, 0xba }, + // ANSI roles are semantic colors, independent of peach string syntax. + .palette = .{ + .{ 0x35, 0x38, 0x3e }, + .{ 0xf2, 0x8a, 0x81 }, + .{ 0xb5, 0xce, 0xa8 }, + .{ 0xd7, 0xba, 0x7d }, + .{ 0x75, 0xbe, 0xff }, + .{ 0xd4, 0x9b, 0xce }, + .{ 0x8b, 0xce, 0xba }, + .{ 0xbf, 0xc3, 0xcc }, + .{ 0xaa, 0xae, 0xb8 }, + .{ 0xf2, 0x8a, 0x81 }, + .{ 0xb5, 0xce, 0xa8 }, + .{ 0xd7, 0xba, 0x7d }, + .{ 0x75, 0xbe, 0xff }, + .{ 0xd4, 0x9b, 0xce }, + .{ 0x8b, 0xce, 0xba }, + .{ 0xbf, 0xc3, 0xcc }, + }, +}; diff --git a/src/themes/harvest.zig b/src/themes/harvest.zig new file mode 100644 index 00000000..0536ffa8 --- /dev/null +++ b/src/themes/harvest.zig @@ -0,0 +1,50 @@ +//! Pardes harvest: Autumn ember keywords, leaf greens and cream; deep sage command rails. +//! A classic-inspired adaptation, not an exact upstream port. +//! Reference: vendor/themes/autumn.toml. +//! Pardes-specific tag, search, diagnostic and ANSI roles; see docs/themes.md. +pub const theme = .{ + .name = "harvest", + .bg = .{ 0x10, 0x0e, 0x0b }, + .fg = .{ 0xf3, 0xf2, 0xcc }, + .tag_bg = .{ 0x34, 0x3b, 0x2e }, + .tag_fg = .{ 0xd5, 0xd8, 0xb8 }, + .tag_active_bg = .{ 0x42, 0x4d, 0x38 }, + .tag_active_fg = .{ 0xe1, 0xe4, 0xc4 }, + .box = .{ 0xcf, 0xba, 0x8b }, + .box_dim = .{ 0x77, 0x79, 0x53 }, + .kw = .{ 0xf0, 0x7d, 0x63 }, + .str = .{ 0x99, 0xbe, 0x70 }, + .num = .{ 0xfa, 0xd5, 0x66 }, + .comment = .{ 0xef, 0xc7, 0x8c }, + .lineno = .{ 0x6a, 0x64, 0x56 }, + .lineno_active = .{ 0x72, 0x6c, 0x5e }, + .scroll_track = .{ 0x20, 0x1d, 0x17 }, + .scroll_thumb = .{ 0x85, 0x8a, 0x62 }, + .border = .{ 0x59, 0x65, 0x47 }, + .sel_bg = .{ 0x4d, 0x54, 0x38 }, + .sel_fg = .{ 0xf3, 0xf2, 0xcc }, + .search_bg = .{ 0x66, 0x45, 0x2d }, + .search_fg = .{ 0xff, 0xe2, 0xac }, + .diagnostic_error = .{ 0xf5, 0x8a, 0x73 }, + .diagnostic_warning = .{ 0xfa, 0xd5, 0x66 }, + .diagnostic_info = .{ 0x86, 0xc1, 0xb9 }, + .diagnostic_hint = .{ 0xb5, 0xc6, 0x8c }, + .palette = .{ + .{ 0x10, 0x0e, 0x0b }, + .{ 0xf5, 0x8a, 0x73 }, + .{ 0x99, 0xbe, 0x70 }, + .{ 0xfa, 0xd5, 0x66 }, + .{ 0x86, 0xa9, 0xce }, + .{ 0xd0, 0xa5, 0xb5 }, + .{ 0x86, 0xc1, 0xb9 }, + .{ 0xf3, 0xf2, 0xcc }, + .{ 0xa3, 0xac, 0x97 }, + .{ 0xf5, 0x8a, 0x73 }, + .{ 0x99, 0xbe, 0x70 }, + .{ 0xfa, 0xd5, 0x66 }, + .{ 0x86, 0xa9, 0xce }, + .{ 0xd0, 0xa5, 0xb5 }, + .{ 0x86, 0xc1, 0xb9 }, + .{ 0xf3, 0xf2, 0xcc }, + }, +}; diff --git a/src/themes/ink.zig b/src/themes/ink.zig new file mode 100644 index 00000000..d34f8fae --- /dev/null +++ b/src/themes/ink.zig @@ -0,0 +1,49 @@ +//! Pardes ink: Near-black with bright, unambiguous signals for demanding contrast. +//! Original palette; editor chrome, search, diagnostics and ANSI output are +//! designed together. See docs/themes.md for the shared role contract. +pub const theme = .{ + .name = "ink", + .bg = .{ 0x00, 0x00, 0x00 }, + .fg = .{ 0xf3, 0xf7, 0xfc }, + .tag_bg = .{ 0x15, 0x21, 0x2c }, + .tag_fg = .{ 0xd5, 0xe4, 0xf1 }, + .tag_active_bg = .{ 0x22, 0x33, 0x42 }, + .tag_active_fg = .{ 0xe1, 0xee, 0xf9 }, + .box = .{ 0x86, 0xd8, 0xff }, + .box_dim = .{ 0x64, 0x79, 0x8b }, + .kw = .{ 0xd5, 0xb8, 0xff }, + .str = .{ 0xb5, 0xee, 0xae }, + .num = .{ 0xff, 0xd6, 0x94 }, + .comment = .{ 0xa8, 0xe7, 0xf4 }, + .lineno = .{ 0x62, 0x6b, 0x75 }, + .lineno_active = .{ 0x6a, 0x73, 0x7d }, + .scroll_track = .{ 0x15, 0x21, 0x2c }, + .scroll_thumb = .{ 0x8b, 0xa7, 0xbd }, + .border = .{ 0x59, 0x6f, 0x83 }, + .sel_bg = .{ 0x26, 0x52, 0x71 }, + .sel_fg = .{ 0xff, 0xff, 0xff }, + .search_bg = .{ 0x75, 0x50, 0x1b }, + .search_fg = .{ 0xff, 0xff, 0xff }, + .diagnostic_error = .{ 0xff, 0xb1, 0xb1 }, + .diagnostic_warning = .{ 0xff, 0xe2, 0x9c }, + .diagnostic_info = .{ 0x9b, 0xde, 0xff }, + .diagnostic_hint = .{ 0xc6, 0xe4, 0xb5 }, + .palette = .{ + .{ 0x00, 0x00, 0x00 }, + .{ 0xff, 0xb1, 0xb1 }, + .{ 0xb5, 0xee, 0xae }, + .{ 0xff, 0xe2, 0x9c }, + .{ 0x9b, 0xde, 0xff }, + .{ 0xd5, 0xb8, 0xff }, + .{ 0xc6, 0xe4, 0xb5 }, + .{ 0xf3, 0xf7, 0xfc }, + .{ 0xb9, 0xc6, 0xd2 }, + .{ 0xff, 0xb1, 0xb1 }, + .{ 0xb5, 0xee, 0xae }, + .{ 0xff, 0xd6, 0x94 }, + .{ 0x9b, 0xde, 0xff }, + .{ 0xd5, 0xb8, 0xff }, + .{ 0xc6, 0xe4, 0xb5 }, + .{ 0xf3, 0xf7, 0xfc }, + }, +}; diff --git a/src/themes/lagoon.zig b/src/themes/lagoon.zig new file mode 100644 index 00000000..cc444ff1 --- /dev/null +++ b/src/themes/lagoon.zig @@ -0,0 +1,50 @@ +//! Pardes lagoon: Material Oceanic slate and pastel syntax; deep sea-glass command rails. +//! A classic-inspired adaptation, not an exact upstream port. +//! Reference: vendor/themes/material_oceanic.toml (inherits material_deep_ocean.toml). +//! Pardes-specific tag, search, diagnostic and ANSI roles; see docs/themes.md. +pub const theme = .{ + .name = "lagoon", + .bg = .{ 0x34, 0x46, 0x4c }, + .fg = .{ 0xba, 0xc9, 0xce }, + .tag_bg = .{ 0x29, 0x44, 0x3f }, + .tag_fg = .{ 0xbb, 0xd7, 0xcb }, + .tag_active_bg = .{ 0x34, 0x57, 0x4d }, + .tag_active_fg = .{ 0xc7, 0xe3, 0xd7 }, + .box = .{ 0x89, 0xc7, 0xb4 }, + .box_dim = .{ 0x59, 0x7e, 0x71 }, + .kw = .{ 0xd5, 0xa8, 0xed }, + .str = .{ 0xc3, 0xe8, 0x8d }, + .num = .{ 0xff, 0xad, 0x91 }, + .comment = .{ 0x92, 0xe1, 0xd1 }, + .lineno = .{ 0x7b, 0x91, 0x94 }, + .lineno_active = .{ 0x83, 0x99, 0x9c }, + .scroll_track = .{ 0x3a, 0x4c, 0x50 }, + .scroll_thumb = .{ 0x72, 0x97, 0x8a }, + .border = .{ 0x50, 0x74, 0x68 }, + .sel_bg = .{ 0x39, 0x5b, 0x65 }, + .sel_fg = .{ 0xe6, 0xf2, 0xf3 }, + .search_bg = .{ 0x60, 0x53, 0x27 }, + .search_fg = .{ 0xff, 0xf0, 0xb9 }, + .diagnostic_error = .{ 0xff, 0x9d, 0xa1 }, + .diagnostic_warning = .{ 0xff, 0xcb, 0x6b }, + .diagnostic_info = .{ 0x82, 0xc9, 0xfa }, + .diagnostic_hint = .{ 0x89, 0xdd, 0xcc }, + .palette = .{ + .{ 0x34, 0x46, 0x4c }, + .{ 0xff, 0x9d, 0xa1 }, + .{ 0xc3, 0xe8, 0x8d }, + .{ 0xff, 0xcb, 0x6b }, + .{ 0x82, 0xc9, 0xfa }, + .{ 0xd5, 0xa8, 0xed }, + .{ 0x89, 0xdd, 0xcc }, + .{ 0xba, 0xc9, 0xce }, + .{ 0xad, 0xba, 0xbc }, + .{ 0xff, 0x9d, 0xa1 }, + .{ 0xc3, 0xe8, 0x8d }, + .{ 0xff, 0xcb, 0x6b }, + .{ 0x82, 0xc9, 0xfa }, + .{ 0xd5, 0xa8, 0xed }, + .{ 0x89, 0xdd, 0xcc }, + .{ 0xba, 0xc9, 0xce }, + }, +}; diff --git a/src/themes/orchard.zig b/src/themes/orchard.zig new file mode 100644 index 00000000..17ac801a --- /dev/null +++ b/src/themes/orchard.zig @@ -0,0 +1,49 @@ +//! Pardes orchard: Near-black evergreen, with lively leaf comments and plum syntax. +//! Original palette; editor chrome, search, diagnostics and ANSI output are +//! designed together. See docs/themes.md for the shared role contract. +pub const theme = .{ + .name = "orchard", + .bg = .{ 0x0d, 0x14, 0x10 }, + .fg = .{ 0xe5, 0xee, 0xe5 }, + .tag_bg = .{ 0x1d, 0x28, 0x24 }, + .tag_fg = .{ 0xb2, 0xc3, 0xb8 }, + .tag_active_bg = .{ 0x26, 0x36, 0x2d }, + .tag_active_fg = .{ 0xbe, 0xcf, 0xc4 }, + .box = .{ 0xad, 0xcc, 0x91 }, + .box_dim = .{ 0x4c, 0x63, 0x56 }, + .kw = .{ 0xc9, 0xb0, 0xe4 }, + .str = .{ 0xb6, 0xce, 0x94 }, + .num = .{ 0xe9, 0xbf, 0x91 }, + .comment = .{ 0xb9, 0xe5, 0x8f }, + .lineno = .{ 0x59, 0x67, 0x5c }, + .lineno_active = .{ 0x61, 0x6f, 0x64 }, + .scroll_track = .{ 0x15, 0x20, 0x19 }, + .scroll_thumb = .{ 0x63, 0x7f, 0x6a }, + .border = .{ 0x35, 0x48, 0x3d }, + .sel_bg = .{ 0x40, 0x5d, 0x4b }, + .sel_fg = .{ 0xf5, 0xf7, 0xee }, + .search_bg = .{ 0x57, 0x47, 0x2a }, + .search_fg = .{ 0xf7, 0xe4, 0xa5 }, + .diagnostic_error = .{ 0xf1, 0xa3, 0x9b }, + .diagnostic_warning = .{ 0xe9, 0xc6, 0x80 }, + .diagnostic_info = .{ 0x9b, 0xcb, 0xd5 }, + .diagnostic_hint = .{ 0xb6, 0xc5, 0x9e }, + .palette = .{ + .{ 0x0d, 0x14, 0x10 }, + .{ 0xf1, 0xa3, 0x9b }, + .{ 0xb6, 0xce, 0x94 }, + .{ 0xe9, 0xc6, 0x80 }, + .{ 0x9b, 0xcb, 0xd5 }, + .{ 0xc9, 0xb0, 0xe4 }, + .{ 0xb6, 0xc5, 0x9e }, + .{ 0xe5, 0xee, 0xe5 }, + .{ 0x96, 0xa6, 0x9b }, + .{ 0xf1, 0xa3, 0x9b }, + .{ 0xb6, 0xce, 0x94 }, + .{ 0xe9, 0xbf, 0x91 }, + .{ 0x9b, 0xcb, 0xd5 }, + .{ 0xc9, 0xb0, 0xe4 }, + .{ 0xb6, 0xc5, 0x9e }, + .{ 0xe5, 0xee, 0xe5 }, + }, +}; diff --git a/src/themes/orchard_black.zig b/src/themes/orchard_black.zig new file mode 100644 index 00000000..bf53c66a --- /dev/null +++ b/src/themes/orchard_black.zig @@ -0,0 +1,49 @@ +//! Pardes orchard_black: Pure-black evergreen with bright leaf comments and luminous garden ink. +//! Original palette; editor chrome, search, diagnostics and ANSI output are +//! designed together. See docs/themes.md for the shared role contract. +pub const theme = .{ + .name = "orchard_black", + .bg = .{ 0x00, 0x00, 0x00 }, + .fg = .{ 0xf2, 0xf8, 0xef }, + .tag_bg = .{ 0x1d, 0x28, 0x24 }, + .tag_fg = .{ 0xb2, 0xc3, 0xb8 }, + .tag_active_bg = .{ 0x26, 0x36, 0x2d }, + .tag_active_fg = .{ 0xbe, 0xcf, 0xc4 }, + .box = .{ 0xad, 0xcc, 0x91 }, + .box_dim = .{ 0x4c, 0x63, 0x56 }, + .kw = .{ 0xc9, 0xb0, 0xe4 }, + .str = .{ 0xb6, 0xce, 0x94 }, + .num = .{ 0xe9, 0xbf, 0x91 }, + .comment = .{ 0xb9, 0xe5, 0x8f }, + .lineno = .{ 0x59, 0x67, 0x5c }, + .lineno_active = .{ 0x61, 0x6f, 0x64 }, + .scroll_track = .{ 0x11, 0x18, 0x13 }, + .scroll_thumb = .{ 0x63, 0x7f, 0x6a }, + .border = .{ 0x35, 0x48, 0x3d }, + .sel_bg = .{ 0x40, 0x5d, 0x4b }, + .sel_fg = .{ 0xf5, 0xf7, 0xee }, + .search_bg = .{ 0x57, 0x47, 0x2a }, + .search_fg = .{ 0xf7, 0xe4, 0xa5 }, + .diagnostic_error = .{ 0xf1, 0xa3, 0x9b }, + .diagnostic_warning = .{ 0xe9, 0xc6, 0x80 }, + .diagnostic_info = .{ 0x9b, 0xcb, 0xd5 }, + .diagnostic_hint = .{ 0xb6, 0xc5, 0x9e }, + .palette = .{ + .{ 0x00, 0x00, 0x00 }, + .{ 0xf1, 0xa3, 0x9b }, + .{ 0xb6, 0xce, 0x94 }, + .{ 0xe9, 0xc6, 0x80 }, + .{ 0x9b, 0xcb, 0xd5 }, + .{ 0xc9, 0xb0, 0xe4 }, + .{ 0xb6, 0xc5, 0x9e }, + .{ 0xf2, 0xf8, 0xef }, + .{ 0x96, 0xa6, 0x9b }, + .{ 0xf1, 0xa3, 0x9b }, + .{ 0xb6, 0xce, 0x94 }, + .{ 0xe9, 0xbf, 0x91 }, + .{ 0x9b, 0xcb, 0xd5 }, + .{ 0xc9, 0xb0, 0xe4 }, + .{ 0xb6, 0xc5, 0x9e }, + .{ 0xf2, 0xf8, 0xef }, + }, +}; diff --git a/src/themes/paper.zig b/src/themes/paper.zig new file mode 100644 index 00000000..7bfb891d --- /dev/null +++ b/src/themes/paper.zig @@ -0,0 +1,49 @@ +//! Pardes paper: Uncoated paper with graphite text and restrained teal accents. +//! Original palette; editor chrome, search, diagnostics and ANSI output are +//! designed together. See docs/themes.md for the shared role contract. +pub const theme = .{ + .name = "paper", + .bg = .{ 0xf5, 0xf3, 0xed }, + .fg = .{ 0x30, 0x3b, 0x3b }, + .tag_bg = .{ 0xe8, 0xea, 0xe3 }, + .tag_fg = .{ 0x52, 0x62, 0x5a }, + .tag_active_bg = .{ 0xdc, 0xe5, 0xdc }, + .tag_active_fg = .{ 0x4a, 0x5a, 0x52 }, + .box = .{ 0x3c, 0x6b, 0x56 }, + .box_dim = .{ 0x91, 0xa3, 0x95 }, + .kw = .{ 0x73, 0x51, 0x90 }, + .str = .{ 0x41, 0x68, 0x48 }, + .num = .{ 0x87, 0x58, 0x26 }, + .comment = .{ 0x2e, 0x71, 0x59 }, + .lineno = .{ 0x8b, 0x91, 0x85 }, + .lineno_active = .{ 0x83, 0x89, 0x7d }, + .scroll_track = .{ 0xe5, 0xe8, 0xdf }, + .scroll_thumb = .{ 0x88, 0x9b, 0x8b }, + .border = .{ 0xb5, 0xc1, 0xb3 }, + .sel_bg = .{ 0xc8, 0xdf, 0xd1 }, + .sel_fg = .{ 0x20, 0x3e, 0x31 }, + .search_bg = .{ 0xf0, 0xdd, 0xa8 }, + .search_fg = .{ 0x61, 0x46, 0x1c }, + .diagnostic_error = .{ 0xa3, 0x39, 0x38 }, + .diagnostic_warning = .{ 0x80, 0x57, 0x0c }, + .diagnostic_info = .{ 0x30, 0x65, 0x75 }, + .diagnostic_hint = .{ 0x52, 0x6d, 0x3e }, + .palette = .{ + .{ 0x30, 0x3b, 0x3b }, + .{ 0xa3, 0x39, 0x38 }, + .{ 0x41, 0x68, 0x48 }, + .{ 0x80, 0x57, 0x0c }, + .{ 0x30, 0x65, 0x75 }, + .{ 0x73, 0x51, 0x90 }, + .{ 0x52, 0x6d, 0x3e }, + .{ 0x30, 0x3b, 0x3b }, + .{ 0x64, 0x6e, 0x65 }, + .{ 0xa3, 0x39, 0x38 }, + .{ 0x41, 0x68, 0x48 }, + .{ 0x87, 0x58, 0x26 }, + .{ 0x30, 0x65, 0x75 }, + .{ 0x73, 0x51, 0x90 }, + .{ 0x52, 0x6d, 0x3e }, + .{ 0x30, 0x3b, 0x3b }, + }, +}; diff --git a/src/themes/solarium.zig b/src/themes/solarium.zig new file mode 100644 index 00000000..84866830 --- /dev/null +++ b/src/themes/solarium.zig @@ -0,0 +1,50 @@ +//! Pardes solarium: Solarized deep blue-green and warm ink; teal command rails. +//! A classic-inspired adaptation, not an exact upstream port. +//! Reference: vendor/themes/solarized_dark.toml. +//! Pardes-specific tag, search, diagnostic and ANSI roles; see docs/themes.md. +pub const theme = .{ + .name = "solarium", + .bg = .{ 0x17, 0x3e, 0x45 }, + .fg = .{ 0xa7, 0xb7, 0xae }, + .tag_bg = .{ 0x12, 0x3f, 0x43 }, + .tag_fg = .{ 0xc7, 0xd4, 0xbd }, + .tag_active_bg = .{ 0x21, 0x4e, 0x50 }, + .tag_active_fg = .{ 0xd3, 0xe0, 0xc9 }, + .box = .{ 0xd5, 0xbc, 0x72 }, + .box_dim = .{ 0x4c, 0x79, 0x72 }, + .kw = .{ 0x6a, 0xae, 0xdd }, + .str = .{ 0xaf, 0xbd, 0x52 }, + .num = .{ 0xd9, 0x9a, 0x5e }, + .comment = .{ 0xe2, 0xc4, 0x6e }, + .lineno = .{ 0x64, 0x7e, 0x7c }, + .lineno_active = .{ 0x6c, 0x86, 0x84 }, + .scroll_track = .{ 0x1c, 0x45, 0x4c }, + .scroll_thumb = .{ 0x69, 0x93, 0x8a }, + .border = .{ 0x38, 0x65, 0x5f }, + .sel_bg = .{ 0x27, 0x56, 0x5c }, + .sel_fg = .{ 0xee, 0xe8, 0xd5 }, + .search_bg = .{ 0x64, 0x53, 0x26 }, + .search_fg = .{ 0xff, 0xf0, 0xb9 }, + .diagnostic_error = .{ 0xed, 0x84, 0x77 }, + .diagnostic_warning = .{ 0xdf, 0xb6, 0x5b }, + .diagnostic_info = .{ 0x6a, 0xae, 0xdd }, + .diagnostic_hint = .{ 0x69, 0xbd, 0xb0 }, + .palette = .{ + .{ 0x17, 0x3e, 0x45 }, + .{ 0xed, 0x84, 0x77 }, + .{ 0xaf, 0xbd, 0x52 }, + .{ 0xdf, 0xb6, 0x5b }, + .{ 0x6a, 0xae, 0xdd }, + .{ 0xb3, 0x9d, 0xdc }, + .{ 0x69, 0xbd, 0xb0 }, + .{ 0xa7, 0xb7, 0xae }, + .{ 0x91, 0xa8, 0xa8 }, + .{ 0xed, 0x84, 0x77 }, + .{ 0xaf, 0xbd, 0x52 }, + .{ 0xdf, 0xb6, 0x5b }, + .{ 0x6a, 0xae, 0xdd }, + .{ 0xb3, 0x9d, 0xdc }, + .{ 0x69, 0xbd, 0xb0 }, + .{ 0xa7, 0xb7, 0xae }, + }, +}; diff --git a/src/themes/spectrum.zig b/src/themes/spectrum.zig new file mode 100644 index 00000000..8c43fada --- /dev/null +++ b/src/themes/spectrum.zig @@ -0,0 +1,50 @@ +//! Pardes spectrum: Monokai Pro charcoal and candy syntax; warm umber command rails. +//! A classic-inspired adaptation, not an exact upstream port. +//! Reference: vendor/themes/monokai_pro.toml. +//! Pardes-specific tag, search, diagnostic and ANSI roles; see docs/themes.md. +pub const theme = .{ + .name = "spectrum", + .bg = .{ 0x10, 0x0e, 0x11 }, + .fg = .{ 0xfc, 0xfc, 0xfa }, + .tag_bg = .{ 0x3b, 0x38, 0x30 }, + .tag_fg = .{ 0xdd, 0xd8, 0xbb }, + .tag_active_bg = .{ 0x4d, 0x47, 0x36 }, + .tag_active_fg = .{ 0xe9, 0xe4, 0xc7 }, + .box = .{ 0xff, 0xd8, 0x66 }, + .box_dim = .{ 0x8b, 0x80, 0x56 }, + .kw = .{ 0xff, 0x61, 0x88 }, + .str = .{ 0xa9, 0xdc, 0x76 }, + .num = .{ 0xab, 0x9d, 0xf2 }, + .comment = .{ 0xff, 0xe0, 0x9b }, + .lineno = .{ 0x68, 0x61, 0x6b }, + .lineno_active = .{ 0x70, 0x69, 0x73 }, + .scroll_track = .{ 0x21, 0x1d, 0x23 }, + .scroll_thumb = .{ 0x91, 0x8b, 0x72 }, + .border = .{ 0x6a, 0x64, 0x50 }, + .sel_bg = .{ 0x51, 0x43, 0x5c }, + .sel_fg = .{ 0xfc, 0xfc, 0xfa }, + .search_bg = .{ 0x66, 0x56, 0x27 }, + .search_fg = .{ 0xff, 0xf0, 0xb0 }, + .diagnostic_error = .{ 0xff, 0x79, 0x9b }, + .diagnostic_warning = .{ 0xff, 0xd8, 0x66 }, + .diagnostic_info = .{ 0x78, 0xdc, 0xe8 }, + .diagnostic_hint = .{ 0xa9, 0xdc, 0x76 }, + .palette = .{ + .{ 0x10, 0x0e, 0x11 }, + .{ 0xff, 0x79, 0x9b }, + .{ 0xa9, 0xdc, 0x76 }, + .{ 0xff, 0xd8, 0x66 }, + .{ 0x8f, 0xb5, 0xf5 }, + .{ 0xab, 0x9d, 0xf2 }, + .{ 0x78, 0xdc, 0xe8 }, + .{ 0xfc, 0xfc, 0xfa }, + .{ 0xad, 0xa5, 0xae }, + .{ 0xff, 0x79, 0x9b }, + .{ 0xa9, 0xdc, 0x76 }, + .{ 0xff, 0xd8, 0x66 }, + .{ 0x8f, 0xb5, 0xf5 }, + .{ 0xab, 0x9d, 0xf2 }, + .{ 0x78, 0xdc, 0xe8 }, + .{ 0xfc, 0xfc, 0xfa }, + }, +}; diff --git a/src/tutor.txt b/src/tutor.txt index b2110dd8..7ee56add 100644 --- a/src/tutor.txt +++ b/src/tutor.txt @@ -59,10 +59,13 @@ 2-1 (hold middle, click left) execute the middle word WITH the left selection as its argument - Every tag with text leads with Save; a terminal also has Filter. - Images and PDFs show "New Del". The top bar is: + Every tag with text leads with Save; a terminal also has Togglettymode + and Filter. Tty opens a terminal; New belongs to each column tag. + Images show "Tty Del Collapse"; PDFs also have PdfSections and PdfTint. + Every pane has Collapse: hide its body, then execute again to expand it. + The top bar is: - New Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor + Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Kill Reach it from the keyboard with `k` off the topmost tagline; `j` comes @@ -119,7 +122,10 @@ Alt-c move the active pane into a fresh column — any pane, not just a terminal. A no-op if it is alone in its column, or if the sixth column already exists. - New Newcol in the top bar: a pane, or a pane in a new column + New in a column tag: a new scratch pane + Newcol in the top bar: a pane in a new column + Tty new terminal in the calling pane's directory + Togglettymode in a terminal tag: switch editor/raw mode (Ctrl-B) Joincol delete this column, move its panes to the one right Del in a pane's own tag: close it -- cgit v1.3