const std = @import("std"); const builtin = @import("builtin"); const layout = @import("layout.zig"); const limits = @import("memory.zig").limits; const pardes = @import("pardes.zig"); const builtins = @import("builtins.zig"); const Key = pardes.Key; const Mouse = pardes.Mouse; const Builtin = builtins.registry.Builtin(); // Printable shift is encoded in cp; shift only constrains chords that request it. pub const Chord = struct { cp: u21, ctrl: bool = false, alt: bool = false, shift: bool = false, }; // Leader paths apply in body normal mode, not tags or raw terminals. pub const leader: []const Chord = &.{.{ .cp = ' ' }}; pub const leader_help: u8 = '?'; const undecided: []const u8 = ""; // null means word/chord-only. Every enabled builtin must explicitly choose a path. pub const leader_path = paths: { var table = std.EnumArray(Builtin, ?[]const u8).initDefault(@as(?[]const u8, undecided), .{ .Help = &[_]u8{leader_help}, .Hover = "lk", .Rename = "lr", .CodeAction = "la", .SelectRefs = "lh", .Symbols = "ls", .WsSymbols = "lS", .Diagnostics = "ld", .WsDiagnostics = "lD", .Lspinfo = "li", .Lspwhy = "lw", .Callers = "lc", .Callees = "lC", .Supertypes = "lt", .Subtypes = "lT", .Del = "d", .DelAbove = null, .DelBelow = null, .Filter = null, .Mode = null, .Togglettymode = null, .Collapse = null, .Repl = null, .Kill = null, .Exit = null, .ClipYank = "y", .ClipYankMain = "Y", .ClipPaste = "p", .ClipPasteBefore = "P", .ClipReplace = "R", .Save = "fs", .New = "fn", .Tty = "nt", .Find = "ff", .Grep = "fg", .Config = "fc", .LocationsConfig = null, .Tutor = "ht", .Changelog = "hc", .Messages = "hm", .Newcol = "cn", .Delcol = "cd", .Joincol = "cj", .Debug = "td", .Msg = null, .Colors = "tc", .FocusTint = null, .ColumnTags = null, .SyntaxBold = null, .Verbose = null, .TreeContextTagStyle = null, .Wrap = "tw", .Tagbottom = "tb", .NextColor = "tn", .ThemeSel = "tt", .Theme = null, .Shell = null, .Petscii = "tp", .Palette = "tl", .Ascii = "ta", .Dump = "sd", .Restore = "sr", .Left = "wh", .Down = "wj", .Up = "wk", .Right = "wl", .Back = "jo", .Forward = "ji", .Last = "jj", .Jumplist = "jl", .Look = null, .Mini = null, .Exec = null, }); if (builtins.capabilities.font_picker) { table.set(.FontSel, "tf"); table.set(.Font, null); table.set(.TaglineSize, null); } if (pardes.platform == .gui) table.set(.Pet, null); table.set(.MessageAnimation, null); table.set(.MessageLinger, null); table.set(.MessageFall, null); table.set(.MessageDissolve, null); table.set(.DumpDir, null); if (builtins.capabilities.window_opacity) table.set(.WindowOpacity, null); if (builtins.capabilities.window_blur) table.set(.WindowBlur, null); if (builtins.capabilities.ligatures) table.set(.Ligatures, null); if (builtins.capabilities.panel_transitions) { table.set(.PanelSlide, "as"); table.set(.PanelZoom, "az"); table.set(.PanelDissolve, "ad"); table.set(.PanelAscii, "aa"); table.set(.PanelVertical, "av"); table.set(.PanelEdges, "ae"); table.set(.PanelFall, "af"); table.set(.PanelWave, "aw"); table.set(.PanelCurtain, "ac"); table.set(.PanelScramble, "ar"); table.set(.PanelType, "at"); } if (builtins.capabilities.scene_shaders) { table.set(.Crt, "tr"); table.set(.Ripple, "tR"); table.set(.Glitch, "tg"); } if (builtins.EffectCode.enabled) table.set(.EffectCode, null); if (builtins.TreeContext.enabled) table.set(.TreeContext, null); if (builtins.Tty9p.enabled) table.set(.Tty9p, "n9"); if (pardes.hosted) { table.set(.ThemeFile, null); table.set(.DumpThemes, null); table.set(.Mount, null); table.set(.Unmount, null); } if (pardes.can_attach) { table.set(.Attach, "sa"); table.set(.Detach, "sD"); } if (builtins.Peek.enabled) { table.set(.Peek, null); table.set(.Poke, null); table.set(.Hexdump, null); table.set(.Gpio, null); } if (pardes.pdf_enabled) { table.set(.PdfTint, "ti"); table.set(.PdfSections, "ts"); table.set(.PdfFit, "tz"); } for (std.enums.values(Builtin)) |b| if (table.get(b)) |p| { if (std.mem.eql(u8, p, undecided)) @compileError("builtin has no leader path decided: " ++ @tagName(b)); }; break :paths table; }; test "Space-k is unbound while Exit, Kill and other k chords remain available" { try std.testing.expect(leader_path.get(.Kill) == null); try std.testing.expect(leader_path.get(.Exit) == null); try std.testing.expectEqualStrings("wk", leader_path.get(.Up).?); try std.testing.expectEqualStrings("lk", leader_path.get(.Hover).?); try std.testing.expectEqualStrings("d", leader_path.get(.Del).?); for (pardes.builtin_rows) |row| if (row.cmd == .Kill) { try std.testing.expect(row.path == null); try std.testing.expect(std.mem.indexOf(u8, row.line, "SPC") == null); try std.testing.expect(std.mem.indexOf(u8, row.line, "Kill") != null); }; const p = try pardes.Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); const pane = try p.setTestFile("first\nsecond\n"); pane.body.cur_row = 1; while (p.nextEffect()) |_| {} p.update(.{ .key = .{ .cp = ' ' } }); try std.testing.expect(p.leader_on); p.update(.{ .key = .{ .cp = 'k' } }); try std.testing.expect(!p.leader_on and !p.quit); try std.testing.expectEqual(@as(i32, 1), pane.body.cur_row); while (p.nextEffect()) |effect| try std.testing.expect(effect != .quit); p.update(.{ .key = .{ .cp = 'k' } }); try std.testing.expectEqual(@as(i32, 0), pane.body.cur_row); // Kill stops commands and never quits; Exit quits. try std.testing.expect(p.executeBuiltinLine(p.active, "Kill")); try std.testing.expect(!p.quit); try std.testing.expect(p.executeBuiltinLine(p.active, "Exit")); try std.testing.expect(p.quit); } pub const look_key: []const Chord = &.{.{ .cp = Key.enter }}; pub const exec_key: []const Chord = &.{.{ .cp = Key.tab }}; pub const look_button: Mouse.Button = .right; pub const exec_button: Mouse.Button = .middle; pub const select_button: Mouse.Button = .left; pub const look_cmd: Builtin = .Look; pub const exec_cmd: Builtin = .Exec; // Ctrl-w remains available when a terminal owns printable keys. pub const window_prefix: []const Chord = &.{.{ .cp = 'w', .ctrl = true }}; pub const window_keys = [_]struct { letter: Chord, arrow: Chord, cmd: Builtin }{ .{ .letter = .{ .cp = 'h' }, .arrow = .{ .cp = Key.left }, .cmd = .Left }, .{ .letter = .{ .cp = 'j' }, .arrow = .{ .cp = Key.down }, .cmd = .Down }, .{ .letter = .{ .cp = 'k' }, .arrow = .{ .cp = Key.up }, .cmd = .Up }, .{ .letter = .{ .cp = 'l' }, .arrow = .{ .cp = Key.right }, .cmd = .Right }, }; pub const new_shell_below: []const Chord = &.{.{ .cp = 'n', .alt = true }}; pub const pane_to_new_column: []const Chord = &.{.{ .cp = 'c', .alt = true }}; // Legacy Ctrl-i is Tab; distinguishing them requires the kitty keyboard protocol. pub const jump_keys = [_]struct { chord: Chord, cmd: Builtin }{ .{ .chord = .{ .cp = 'o', .ctrl = true }, .cmd = .Back }, .{ .chord = .{ .cp = 'i', .ctrl = true }, .cmd = .Forward }, }; pub const leave_pane: []const Chord = &.{.{ .cp = Key.escape, .shift = true }}; pub const tty_toggle_default: u21 = 'b'; pub const tty_toggle_alt: []const Chord = &.{.{ .cp = Key.escape, .shift = true }}; // OSC 133 prompt cells are hidden in normal mode; input columns stay intact. 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; // What a pty child is told it is talking to. Every pane is emulated by the // bundled VT, so this describes pardes and never the terminal pardes itself // was launched from — the child inherits the launcher's TERM otherwise, which // under a macOS .app launch is no TERM at all, and `clear`, colour and every // cursor-addressing program fail at the terminfo lookup. There is no installed // `xterm-ghostty` entry to name, and a name with no entry is worse than a // conservative one that every system ships. pub const child_term = "xterm-256color"; pub const child_colorterm = "truecolor"; pub const child_term_program = "pardes"; pub const topbar_str = "Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Exit"; pub const pane_builtins_str = "Tty Collapse Del"; pub const pdf_pane_builtins_str = "Tty PdfSections PdfTint Collapse Del"; pub const file_pane_builtins_str = "Save Tty Collapse Del"; pub const terminal_pane_builtins_str = "Tty Save Mode Filter Collapse Del"; pub const command_pane_builtins_str = "Kill Save Collapse Del"; // Each mode badge is one codepoint. pub const tag_normal = " "; pub const tag_insert = "^"; pub const tag_tty = "$"; pub const tag_image = "img"; pub const command_line: []const Chord = &.{.{ .cp = ':' }}; pub const search: []const Chord = &.{.{ .cp = '/' }}; pub const search_next: []const Chord = &.{.{ .cp = 'n' }}; pub const search_prev: []const Chord = &.{.{ .cp = 'N' }}; pub const pipe_selection: []const Chord = &.{.{ .cp = '|' }}; pub const pipe_selection_to: []const Chord = &.{.{ .cp = '|', .alt = true }}; pub const insert_output: []const Chord = &.{.{ .cp = '!' }}; pub const append_output: []const Chord = &.{.{ .cp = '!', .alt = true }}; // Armed inputs are parsed from their distinct, nonempty tag markers. pub const pipe_marker_to = " |-"; pub const pipe_marker_insert = " !"; pub const pipe_marker_append = " !+"; pub const search_submit: []const Chord = &.{.{ .cp = Key.enter }}; // Del from the keyboard on a pane with open panes both above and below it // asks which of them takes its rows. One key answers; any other keeps the pane. pub const del_question = "Del: give space to k above j below"; pub const del_above: []const Chord = &.{ .{ .cp = 'k' }, .{ .cp = Key.up } }; pub const del_below: []const Chord = &.{ .{ .cp = 'j' }, .{ .cp = Key.down } }; pub const wheel_rows: i32 = 1; pub const wheel_cols: i32 = 4; pub const look_preview_delay_frames: ?u16 = 2; /// The longest any message timing (MessageLinger, MessageFall, /// MessageDissolve) may be set to, in milliseconds. pub const message_ms_max: u16 = 60_000; // Tag fonts keep body-cell geometry; valid sizes are 1...100 percent. pub const gui_tagline_font_percent: u8 = 82; comptime { if (gui_tagline_font_percent == 0 or gui_tagline_font_percent > 100) @compileError("config.gui_tagline_font_percent must be in 1...100"); } // Physical pixels between topbar and pane tag bands; zero disables the rule. pub const gui_topbar_pane_border_px: u8 = 1; // null follows the theme's scrollbar track. pub const gui_topbar_pane_border_rgb: ?[3]u8 = null; // Transparency requires the SDL readback/blit path, even with an opaque theme. pub const gui_transparent: bool = false; // A vertical wheel tick suppresses this many subsequent horizontal ticks. pub const wheel_guard_ticks: u8 = 3; pub fn wheelTick(guard: *u8, vertical: bool) bool { if (vertical) { guard.* = wheel_guard_ticks; return true; } if (guard.* == 0) return true; guard.* -= 1; return false; } test "wheel drift guard" { var g: u8 = 0; try std.testing.expect(wheelTick(&g, false)); try std.testing.expect(wheelTick(&g, false)); if (wheel_guard_ticks == 0) return; // guard disabled: nothing left to check for (0..4) |_| try std.testing.expect(wheelTick(&g, true)); try std.testing.expect(!wheelTick(&g, false)); try std.testing.expect(wheelTick(&g, true)); try std.testing.expect(!wheelTick(&g, false)); for (1..wheel_guard_ticks) |_| try std.testing.expect(!wheelTick(&g, false)); try std.testing.expect(wheelTick(&g, false)); try std.testing.expect(wheelTick(&g, false)); } pub const default_shell = "fish"; // Minimum file gutter width, including the space after the line number. pub const PREFIX_W: u16 = 5; pub const tab_width: u16 = 4; comptime { if (tab_width == 0) @compileError("config.tab_width must be greater than zero"); } pub const wrap_marker = "↩"; // Cursor motion preserves context in both rows and columns when space permits. pub const scroll_off = 3; pub const GUTTER: u16 = 2; pub const MINW: u16 = 10; pub const MINH: u16 = 3; // Accept every UTF-8 byte so word boundaries never split a codepoint. pub fn isFileChar(c: u8) bool { return c >= 0x80 or std.ascii.isAlphanumeric(c) or switch (c) { '.', '-', '+', '/', ':', '@', '_', '~' => true, else => false, }; } pub const cmd_open = "@`"; pub const cmd_close: u8 = '`'; pub fn commandWord(w: []const u8) ?[]const u8 { if (w.len <= cmd_open.len or !std.mem.startsWith(u8, w, cmd_open)) return null; if (w[w.len - 1] != cmd_close) return null; return w[cmd_open.len .. w.len - 1]; } // A complete command word wins over the ordinary file-character scan. pub fn wordBounds(line: []const u8, col: usize) struct { lo: usize, hi: usize } { var i: usize = 0; while (std.mem.indexOfPos(u8, line, i, cmd_open)) |o| { const close = std.mem.indexOfScalarPos(u8, line, o + cmd_open.len, cmd_close) orelse break; if (col >= o and col <= close) return .{ .lo = o, .hi = close + 1 }; i = close + 1; } var lo = col; while (lo > 0 and isFileChar(line[lo - 1])) lo -= 1; var hi = col; while (hi < line.len and isFileChar(line[hi])) hi += 1; return .{ .lo = lo, .hi = hi }; } pub const line_col_sep: u8 = ':'; // Ranges are inclusive and 1-based: path:2-4, path:2:3-7, path:2:3-4:1. pub const range_sep: u8 = '-'; // @p7:10:5 addresses pane 7, line 10, column 5. pub const pane_addr = "@p"; pub const url_schemes = [_][]const u8{ "http://", "https://" }; pub const image_exts = [_][]const u8{ ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ppm", ".pgm", ".tga" }; // Match Helix's fallback for unknown languages and languages without line comments. pub const comment_token_default = "#"; pub const comment_tokens: []const struct { exts: []const []const u8, token: []const u8 } = &.{ .{ .token = "//", .exts = &.{ ".zig", ".zon", ".c", ".h", ".cpp", ".cc", ".cxx", ".hpp", ".hh", ".hxx", ".rs", ".go", ".java", ".scala", ".sc", ".kt", ".kts", ".cs", ".csx", ".php", ".pas", ".pp", ".p", ".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".typ", ".typst", ".swift", ".dart" } }, .{ .token = "#", .exts = &.{ ".py", ".pyw", ".sh", ".bash", ".zsh", ".rb", ".rake", ".ex", ".exs", ".ps1", ".psm1", ".psd1", ".pl", ".pm", ".r", ".jl", ".nix", ".toml", ".yaml", ".yml", ".cmake", ".mk", ".tf" } }, .{ .token = "--", .exts = &.{ ".lua", ".hs", ".lhs", ".elm", ".sql", ".adb", ".ads", ".ada" } }, .{ .token = ";", .exts = &.{ ".clj", ".cljs", ".cljc", ".edn", ".el", ".lisp", ".scm", ".asm", ".s" } }, .{ .token = "%", .exts = &.{ ".erl", ".hrl", ".tex", ".cls", ".sty" } }, .{ .token = "!", .exts = &.{ ".f", ".for", ".ftn", ".f90", ".f95", ".f03", ".f08" } }, .{ .token = "\"", .exts = &.{ ".vim", ".vimrc" } }, }; pub const search_marker = " /"; pub const find_marker = " Find /"; pub const grep_marker = " Grep /"; pub const rename_marker = " Rename /"; pub const symbol_marker = " WsSymbols /"; pub const save_marker = " Save "; pub const select_marker = " Select /"; pub const split_marker = " Split /"; pub const pipe_marker = " |"; pub const search_buffer = "+Search"; pub const help_buffer = "+Help"; pub const config_buffer = "+Config"; pub const effect_code_buffer = "+EffectCode"; pub const jumps_buffer = "+Jumps"; pub const themes_buffer = "+Themes"; pub const fonts_buffer = "+Fonts"; pub const pdf_sections_buffer = "+PdfSections"; pub const pdf_links_buffer = "+Links"; pub const hover_buffer = "+Hover"; pub const lsp_buffer = "+Lsp"; pub const changelog_buffer = "+Changelog"; pub const messages_buffer = "+Messages"; pub const peek_buffer = "+Peek"; pub const hexdump_buffer = "+Hexdump"; pub const gpio_buffer = "+Gpio"; pub const scratch_buffer = "+New"; pub const errors_buffer = "+Errors"; // Modal bindings below are checked against Helix by hxdiff and hxparity. pub const goto_prefix = 'g'; pub const view_prefix = 'z'; pub const match_prefix = 'm'; pub const replace_prefix = 'r'; pub const next_prefix = ']'; pub const prev_prefix = '['; pub const find_char_fwd = 'f'; pub const find_char_back = 'F'; pub const till_char_fwd = 't'; pub const till_char_back = 'T'; pub const repeat_find: []const Chord = &.{.{ .cp = '.', .alt = true }}; pub const move_left: []const Chord = &.{ .{ .cp = 'h' }, .{ .cp = Key.left } }; pub const move_right: []const Chord = &.{ .{ .cp = 'l' }, .{ .cp = Key.right } }; pub const move_down: []const Chord = &.{ .{ .cp = 'j' }, .{ .cp = Key.down } }; pub const move_up: []const Chord = &.{ .{ .cp = 'k' }, .{ .cp = Key.up } }; pub const next_word_start: []const Chord = &.{.{ .cp = 'w' }}; pub const prev_word_start: []const Chord = &.{.{ .cp = 'b' }}; pub const next_word_end: []const Chord = &.{.{ .cp = 'e' }}; pub const next_long_word_start: []const Chord = &.{.{ .cp = 'W' }}; pub const prev_long_word_start: []const Chord = &.{.{ .cp = 'B' }}; pub const next_long_word_end: []const Chord = &.{.{ .cp = 'E' }}; pub const line_start: []const Chord = &.{ .{ .cp = '0' }, .{ .cp = Key.home } }; pub const line_end: []const Chord = &.{ .{ .cp = '$' }, .{ .cp = Key.end } }; pub const line_first_nonws: []const Chord = &.{.{ .cp = '^' }}; // Bare G does nothing; ge reaches the last line. pub const goto_line: []const Chord = &.{.{ .cp = 'G' }}; pub const half_page_down: []const Chord = &.{.{ .cp = 'd', .ctrl = true }}; pub const half_page_up: []const Chord = &.{.{ .cp = 'u', .ctrl = true }}; pub const page_down: []const Chord = &.{ .{ .cp = 'f', .ctrl = true }, .{ .cp = Key.page_down } }; pub const page_up: []const Chord = &.{ .{ .cp = 'b', .ctrl = true }, .{ .cp = Key.page_up } }; pub const view_top: []const Chord = &.{.{ .cp = 't' }}; pub const view_center: []const Chord = &.{ .{ .cp = 'z' }, .{ .cp = 'c' } }; pub const view_bottom: []const Chord = &.{.{ .cp = 'b' }}; pub const view_scroll_down: []const Chord = &.{ .{ .cp = 'j' }, .{ .cp = Key.down } }; pub const view_scroll_up: []const Chord = &.{ .{ .cp = 'k' }, .{ .cp = Key.up } }; pub const goto_file_start: []const Chord = &.{.{ .cp = 'g' }}; pub const goto_last_line: []const Chord = &.{.{ .cp = 'e' }}; pub const goto_line_start: []const Chord = &.{.{ .cp = 'h' }}; pub const goto_line_end: []const Chord = &.{.{ .cp = 'l' }}; pub const goto_first_nonws: []const Chord = &.{.{ .cp = 's' }}; pub const goto_line_down: []const Chord = &.{.{ .cp = 'j' }}; pub const goto_line_up: []const Chord = &.{.{ .cp = 'k' }}; pub const goto_column: []const Chord = &.{.{ .cp = '|' }}; pub const goto_view_top: []const Chord = &.{.{ .cp = 't' }}; pub const goto_view_center: []const Chord = &.{.{ .cp = 'c' }}; pub const goto_view_bottom: []const Chord = &.{.{ .cp = 'b' }}; pub const goto_definition: []const Chord = &.{.{ .cp = 'd' }}; pub const goto_declaration: []const Chord = &.{.{ .cp = 'D' }}; pub const goto_type_definition: []const Chord = &.{.{ .cp = 'y' }}; pub const goto_implementation: []const Chord = &.{.{ .cp = 'i' }}; pub const goto_references: []const Chord = &.{.{ .cp = 'r' }}; pub const match_bracket: []const Chord = &.{.{ .cp = 'm' }}; pub const match_inside = 'i'; pub const match_around = 'a'; pub const surround_add = 's'; pub const surround_replace = 'r'; pub const surround_delete = 'd'; pub const register_prefix = '"'; pub const goto_paragraph: []const Chord = &.{.{ .cp = 'p' }}; pub const add_newline: []const Chord = &.{.{ .cp = ' ' }}; pub const goto_diagnostic: []const Chord = &.{.{ .cp = 'd' }}; pub const goto_diagnostic_end: []const Chord = &.{.{ .cp = 'D' }}; pub const insert: []const Chord = &.{.{ .cp = 'i' }}; pub const append: []const Chord = &.{.{ .cp = 'a' }}; pub const insert_line_start: []const Chord = &.{.{ .cp = 'I' }}; pub const insert_line_end: []const Chord = &.{.{ .cp = 'A' }}; pub const open_below: []const Chord = &.{.{ .cp = 'o' }}; pub const open_above: []const Chord = &.{.{ .cp = 'O' }}; pub const select_mode: []const Chord = &.{.{ .cp = 'v' }}; pub const select_line: []const Chord = &.{.{ .cp = 'x' }}; pub const select_line_bounds: []const Chord = &.{.{ .cp = 'X' }}; pub const shrink_to_line_bounds: []const Chord = &.{.{ .cp = 'x', .alt = true }}; pub const collapse_selection: []const Chord = &.{.{ .cp = ';' }}; pub const flip_selection: []const Chord = &.{.{ .cp = ';', .alt = true }}; pub const select_all: []const Chord = &.{.{ .cp = '%' }}; pub const copy_sel_below: []const Chord = &.{.{ .cp = 'C' }}; pub const copy_sel_above: []const Chord = &.{.{ .cp = 'C', .alt = true }}; pub const keep_primary_sel: []const Chord = &.{.{ .cp = ',' }}; pub const remove_primary_sel: []const Chord = &.{.{ .cp = ',', .alt = true }}; pub const rotate_sel_fwd: []const Chord = &.{.{ .cp = ')' }}; pub const rotate_sel_back: []const Chord = &.{.{ .cp = '(' }}; pub const split_sel_newline: []const Chord = &.{.{ .cp = 's', .alt = true }}; pub const merge_sels: []const Chord = &.{.{ .cp = '-', .alt = true }}; pub const merge_consecutive_sels: []const Chord = &.{.{ .cp = '_', .alt = true }}; pub const trim_sels: []const Chord = &.{.{ .cp = '_' }}; pub const select_regex: []const Chord = &.{.{ .cp = 's' }}; pub const split_regex: []const Chord = &.{.{ .cp = 'S' }}; pub const delete: []const Chord = &.{.{ .cp = 'd' }}; pub const delete_noyank: []const Chord = &.{.{ .cp = 'd', .alt = true }}; pub const change: []const Chord = &.{.{ .cp = 'c' }}; pub const yank: []const Chord = &.{.{ .cp = 'y' }}; pub const replace_with_yank: []const Chord = &.{.{ .cp = 'R' }}; pub const paste_after: []const Chord = &.{.{ .cp = 'p' }}; pub const paste_before: []const Chord = &.{.{ .cp = 'P' }}; pub const switch_case: []const Chord = &.{.{ .cp = '~' }}; pub const to_lowercase: []const Chord = &.{.{ .cp = '`' }}; pub const to_uppercase: []const Chord = &.{.{ .cp = '`', .alt = true }}; pub const join_lines: []const Chord = &.{.{ .cp = 'J' }}; pub const join_select_space: []const Chord = &.{.{ .cp = 'J', .alt = true }}; pub const rotate_contents_fwd: []const Chord = &.{.{ .cp = ')', .alt = true }}; pub const rotate_contents_back: []const Chord = &.{.{ .cp = '(', .alt = true }}; pub const align_selections: []const Chord = &.{.{ .cp = '&' }}; pub const indent: []const Chord = &.{.{ .cp = '>' }}; pub const unindent: []const Chord = &.{.{ .cp = '<' }}; pub const format: []const Chord = &.{.{ .cp = '=' }}; pub const increment: []const Chord = &.{.{ .cp = 'a', .ctrl = true }}; pub const decrement: []const Chord = &.{.{ .cp = 'x', .ctrl = true }}; pub const undo: []const Chord = &.{.{ .cp = 'u' }}; pub const redo: []const Chord = &.{.{ .cp = 'U' }}; pub const comment_toggle: []const Chord = &.{.{ .cp = 'c', .ctrl = true }}; // Body-normal Esc runs Last; other modes cancel input or leave insert mode. pub const escape: []const Chord = &.{.{ .cp = Key.escape }}; pub const insert_backspace_alias: []const Chord = &.{.{ .cp = 'h', .ctrl = true }}; pub const insert_enter_alias: []const Chord = &.{.{ .cp = 'j', .ctrl = true }}; pub const insert_delete_alias: []const Chord = &.{.{ .cp = 'd', .ctrl = true }}; pub const insert_register: []const Chord = &.{.{ .cp = 'r', .ctrl = true }}; pub const delete_word_backward: []const Chord = &.{ .{ .cp = 'w', .ctrl = true }, .{ .cp = Key.backspace, .alt = true } }; pub const delete_word_forward: []const Chord = &.{ .{ .cp = 'd', .alt = true }, .{ .cp = Key.delete, .alt = true } }; pub const kill_to_line_start: []const Chord = &.{.{ .cp = 'u', .ctrl = true }}; pub const kill_to_line_end: []const Chord = &.{.{ .cp = 'k', .ctrl = true }}; pub const Runtime = struct { theme: usize = 0, colors: bool = true, wrap: bool = true, tag_bottom: bool = false, tree_context_tag_style: bool = true, debug: bool = false, focus_tint: bool = true, /// The workspace tag row. Native shells with their own chrome (the macOS /// menu bar) turn it off and carry its commands there instead; the core /// keeps the row as the acme tradition everywhere else. workspace_tag: bool = true, column_tags: bool = true, syntax_bold: bool = false, /// A font's programming ligatures (`->`, `!=`) draw across their cells. /// Only a shell that shapes text has the setting (`Capabilities.ligatures`). ligatures: bool = true, /// Builtins announce themselves on the message row. verbose: bool = true, /// A message falls into its row and dissolves out of it. message_animation: bool = true, /// How long a message stays after the key or click that dismisses it. message_linger_ms: u16 = 800, /// How long its ease-in fall into the row takes. message_fall_ms: u16 = 180, /// How long its ease-out dissolve takes. message_dissolve_ms: u16 = 300, /// Where Dump writes and a relative Restore looks; empty is the default /// ($XDG_DATA_HOME/pardes, else ~/.local/share/pardes). dump_dir: Text(limits.host_path_cap) = .{}, window_opacity: u8 = 100, window_blur: u8 = 0, window_opacity_pending: bool = true, pet: enum { off, cat, frog } = .off, // Effective values change only after a host acknowledges the request. shell: struct { requested: Text(255) = .{}, effective: Text(limits.host_path_cap) = .{}, pending: bool = true, } = .{}, 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, effective_size_unit: FontSizeUnit = .unknown, tagline_percent: u8 = 100, } = .{}, panel_transition: layout.Transition = .off, scene_effects: layout.SceneEffect = .{}, pub fn toggleTransition(state: *Runtime, effect: layout.Transition) void { std.debug.assert(effect != .off); state.panel_transition = if (state.panel_transition == effect) .off else effect; } pub const tagline_percent_min: u8 = 1; pub const tagline_percent_max: u8 = 100; pub fn Text(comptime capacity: usize) type { return struct { bytes: [capacity]u8 = @splat(0), len: std.math.IntFittingRange(0, capacity) = 0, pub fn get(value: *const @This()) []const u8 { return value.bytes[0..value.len]; } pub fn set(value: *@This(), text: []const u8) bool { if (text.len > capacity) return false; @memcpy(value.bytes[0..text.len], text); value.len = @intCast(text.len); return true; } pub fn clear(value: *@This()) void { value.len = 0; } }; } pub const FontSizeUnit = enum { unknown, pixels, points }; 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; } pub const Capabilities = struct { font_picker: bool, panel_transitions: bool, scene_shaders: bool, tagline_font_size: bool, window_opacity: bool = false, window_blur: bool = false, /// The shell shapes text, so it can draw ligatures and stop drawing them. ligatures: bool = false, }; pub const Capability = std.meta.FieldEnum(Capabilities); /// The message timings a setting word sets, each in milliseconds. pub const MessageTime = enum { linger, fall, dissolve }; pub const Toggle = enum { colors, wrap, tree_context_tag_style, tag_bottom, debug, focus_tint, column_tags, syntax_bold, verbose, message_animation, ligatures }; pub const Scene = std.meta.FieldEnum(layout.SceneEffect); pub const Action = union(enum) { toggle: Toggle, shell, theme, font, tagline_size, window_opacity, window_blur, message_ms: MessageTime, dump_dir, transition: layout.Transition, scene: Scene, }; pub const Setting = struct { word: []const u8, action: Action, availability: ?Capability = null, pub fn enabled(setting: Setting, capabilities: Capabilities) bool { const capability = setting.availability orelse return true; return switch (capability) { inline else => |field| @field(capabilities, @tagName(field)), }; } }; // The builtin registry and Config report share this command table. pub const settings = [_]Setting{ .{ .word = "Colors", .action = .{ .toggle = .colors } }, .{ .word = "Wrap", .action = .{ .toggle = .wrap } }, .{ .word = "TreeContextTagStyle", .action = .{ .toggle = .tree_context_tag_style } }, .{ .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 = "Verbose", .action = .{ .toggle = .verbose } }, .{ .word = "MessageAnimation", .action = .{ .toggle = .message_animation } }, .{ .word = "Ligatures", .action = .{ .toggle = .ligatures }, .availability = .ligatures }, .{ .word = "MessageLinger", .action = .{ .message_ms = .linger } }, .{ .word = "MessageFall", .action = .{ .message_ms = .fall } }, .{ .word = "MessageDissolve", .action = .{ .message_ms = .dissolve } }, .{ .word = "DumpDir", .action = .dump_dir }, .{ .word = "Theme", .action = .theme }, .{ .word = "Shell", .action = .shell }, .{ .word = "Font", .action = .font, .availability = .font_picker }, .{ .word = "TaglineSize", .action = .tagline_size, .availability = .font_picker }, .{ .word = "WindowOpacity", .action = .window_opacity, .availability = .window_opacity }, .{ .word = "WindowBlur", .action = .window_blur, .availability = .window_blur }, .{ .word = "PanelSlide", .action = .{ .transition = .slide }, .availability = .panel_transitions }, .{ .word = "PanelZoom", .action = .{ .transition = .zoom }, .availability = .panel_transitions }, .{ .word = "PanelDissolve", .action = .{ .transition = .dissolve }, .availability = .panel_transitions }, .{ .word = "PanelAscii", .action = .{ .transition = .ascii }, .availability = .panel_transitions }, .{ .word = "PanelVertical", .action = .{ .transition = .vertical }, .availability = .panel_transitions }, .{ .word = "PanelEdges", .action = .{ .transition = .edges }, .availability = .panel_transitions }, .{ .word = "PanelFall", .action = .{ .transition = .fall }, .availability = .panel_transitions }, .{ .word = "PanelWave", .action = .{ .transition = .wave }, .availability = .panel_transitions }, .{ .word = "PanelCurtain", .action = .{ .transition = .curtain }, .availability = .panel_transitions }, .{ .word = "PanelScramble", .action = .{ .transition = .scramble }, .availability = .panel_transitions }, .{ .word = "PanelType", .action = .{ .transition = .typewriter }, .availability = .panel_transitions }, .{ .word = "Crt", .action = .{ .scene = .crt }, .availability = .scene_shaders }, .{ .word = "Ripple", .action = .{ .scene = .ripple }, .availability = .scene_shaders }, .{ .word = "Glitch", .action = .{ .scene = .glitch }, .availability = .scene_shaders }, }; pub fn find(name: []const u8) ?Setting { for (settings) |setting| if (std.mem.eql(u8, setting.word, name)) return setting; return null; } pub fn findAction(action: Action) ?Setting { for (settings) |setting| if (std.meta.eql(setting.action, action)) return setting; return null; } fn actionCount(comptime action: Action) comptime_int { var count = 0; for (settings) |setting| count += @intFromBool(std.meta.eql(setting.action, action)); return count; } comptime { @setEvalBranchQuota(20_000); for (settings, 0..) |setting, i| { if (setting.word.len == 0) @compileError("runtime setting has an empty command word"); for (settings[i + 1 ..]) |later| if (std.mem.eql(u8, setting.word, later.word)) @compileError("duplicate runtime setting command word: " ++ setting.word); switch (setting.action) { .font, .tagline_size => if (setting.availability != .font_picker) @compileError("native font settings must use the font-picker capability"), .window_opacity => if (setting.availability != .window_opacity) @compileError("window opacity must use the window-opacity capability"), .window_blur => if (setting.availability != .window_blur) @compileError("window blur must use the window-blur capability"), .transition => if (setting.availability != .panel_transitions) @compileError("panel effects must use the panel-transition capability"), .scene => if (setting.availability != .scene_shaders) @compileError("scene effects must use the scene-shader capability"), // A toggle is plain state any shell can hold, except one for // what only some shells draw: that one exists where they do. .toggle => |field| if (field == .ligatures) { if (setting.availability != .ligatures) @compileError("the ligatures toggle must use the ligatures capability"); } else if (setting.availability != null) @compileError("unconditional toggles cannot carry a backend capability"), else => if (setting.availability != null) @compileError("unconditional settings cannot carry a backend capability"), } } for (std.enums.values(Toggle)) |field| if (actionCount(.{ .toggle = field }) != 1) @compileError("runtime toggle must occur exactly once: " ++ @tagName(field)); if (actionCount(.shell) != 1 or actionCount(.theme) != 1 or actionCount(.font) != 1 or actionCount(.tagline_size) != 1 or actionCount(.window_opacity) != 1 or actionCount(.window_blur) != 1) @compileError("Shell, Theme, Font, TaglineSize, WindowOpacity, and WindowBlur actions must each occur exactly once"); for (std.enums.values(layout.Transition)) |effect| { const expected: comptime_int = @intFromBool(effect != .off); if (actionCount(.{ .transition = effect }) != expected) @compileError("non-off panel transition must occur exactly once: " ++ @tagName(effect)); } for (std.enums.values(Scene)) |effect| { if (actionCount(.{ .scene = effect }) != 1) @compileError("scene effect must occur exactly once: " ++ @tagName(effect)); if (@FieldType(layout.SceneEffect, @tagName(effect)) != bool) @compileError("scene effect fields must be booleans: " ++ @tagName(effect)); } } pub fn apply(state: *Runtime, setting: Setting, argument: ?[]const u8) bool { switch (setting.action) { // Every toggle names a bool field of its own, so one line covers // all of them and a new toggle needs no arm here. Bare, a toggle // flips -- that is what a tagline word is for. With `on` or `off` // it SETS, so the report `writeReport` prints (`Verbose on`) can // be fed straight back as configuration and mean what it says. .toggle => |field| { const want = switchedTo(argument) catch return false; switch (field) { inline else => |f| @field(state, @tagName(f)) = want orelse !@field(state, @tagName(f)), } }, .shell => { const value = std.mem.trim(u8, argument orelse return false, " \t\r\n"); if (value.len == 0 or !state.shell.requested.set(value)) return false; state.shell.pending = true; }, .tagline_size => { const text = std.mem.trim(u8, argument orelse return false, " \t\r\n"); const percent = std.fmt.parseInt(u16, text, 10) catch return false; if (percent < tagline_percent_min or percent > tagline_percent_max) return false; state.font.tagline_percent = @intCast(percent); }, .window_opacity => { const text = std.mem.trim(u8, argument orelse return false, " \t\r\n"); for (text) |byte| if (!std.ascii.isDigit(byte)) return false; const percent = std.fmt.parseInt(u8, text, 10) catch return false; if (percent > 100) return false; state.window_opacity = percent; state.window_opacity_pending = true; }, .window_blur => { const text = std.mem.trim(u8, argument orelse return false, " \t\r\n"); for (text) |byte| if (!std.ascii.isDigit(byte)) return false; const percent = std.fmt.parseInt(u8, text, 10) catch return false; if (percent > 100) return false; state.window_blur = percent; }, // Bare, it returns to the default directory. .dump_dir => if (!state.dump_dir.set(std.mem.trim(u8, argument orelse "", " \t\r\n"))) return false, .message_ms => |which| { const text = std.mem.trim(u8, argument orelse return false, " \t\r\n"); for (text) |byte| if (!std.ascii.isDigit(byte)) return false; const ms = std.fmt.parseInt(u16, text, 10) catch return false; if (ms > message_ms_max) return false; switch (which) { inline else => |w| @field(state, "message_" ++ @tagName(w) ++ "_ms") = ms, } }, // Panel effects are one choice, so `on` picks this one and `off` // turns it off only if it is the one on. .transition => |effect| { const want = switchedTo(argument) catch return false; if (want == null) { state.toggleTransition(effect); } else if (want.?) { state.panel_transition = effect; } else if (state.panel_transition == effect) { state.panel_transition = .off; } }, .scene => |effect| { const want = switchedTo(argument) catch return false; switch (effect) { inline else => |field| { const value = &@field(state.scene_effects, @tagName(field)); value.* = want orelse !value.*; }, } }, .theme, .font => return false, } return true; } /// What `on`, `off` or nothing after a switch word asks for: null flips /// it, as a bare tagline word does. fn switchedTo(argument: ?[]const u8) error{NotOnOrOff}!?bool { const text = std.mem.trim(u8, argument orelse "", " \t\r\n"); if (text.len == 0) return null; if (std.mem.eql(u8, text, "on")) return true; if (std.mem.eql(u8, text, "off")) return false; return error.NotOnOrOff; } // Slices are borrowed for one writeReport call. pub const ReportContext = struct { startup_config_path: ?[]const u8, platform: []const u8, theme_name: []const u8, compiled_default_shell: []const u8, gui_shader_source_mode: ?[]const u8 = null, hover_delay_frames: ?u16, /// Where dumps go when `DumpDir` is unset; empty without a host /// filesystem. default_dump_dir: []const u8 = "", native_images: bool, capabilities: Capabilities, state: *const Runtime, }; fn onOff(value: bool) []const u8 { return if (value) "on" else "off"; } fn shown(text: []const u8) []const u8 { return if (text.len == 0) "(none)" else text; } fn transitionSettingName(transition: layout.Transition) []const u8 { if (transition == .off) return "off"; return findAction(.{ .transition = transition }).?.word; } /// Each line reads as the command that would set it: the word, then the /// value you would type after it. No colons, no units — `WindowOpacity 70` /// is both what Config prints and what you write back. pub fn writeReport(out: *std.Io.Writer, context: ReportContext) !void { const state = context.state; var wrote_transition = false; for (settings) |setting| switch (setting.action) { .toggle => |field| { // A toggle for what this shell does not draw (Ligatures in a // terminal, whose text its emulator draws) is no setting here: // it is not a builtin, so the report does not list it either. if (!setting.enabled(context.capabilities)) continue; const value = switch (field) { inline else => |f| @field(state, @tagName(f)), }; try out.print("{s} {s}\n", .{ setting.word, onOff(value) }); }, .theme => try out.print("{s} {s}\n", .{ setting.word, context.theme_name }), .window_opacity => { if (setting.enabled(context.capabilities)) try out.print("{s} {d}{s}\n", .{ setting.word, state.window_opacity, if (state.window_opacity_pending) " (pending)" else "", }) else try out.print("{s} unsupported\n", .{setting.word}); }, .window_blur => { if (setting.enabled(context.capabilities)) try out.print("{s} {d}\n", .{ setting.word, state.window_blur }) else try out.print("{s} unsupported\n", .{setting.word}); }, .dump_dir => { const dir = if (state.dump_dir.len > 0) state.dump_dir.get() else context.default_dump_dir; try out.print("{s} {s}\n", .{ setting.word, if (dir.len > 0) dir else "(none)" }); }, .message_ms => |which| try out.print("{s} {d}\n", .{ setting.word, switch (which) { inline else => |w| @field(state, "message_" ++ @tagName(w) ++ "_ms"), } }), .shell => { const chosen = state.shell.requested.get(); try out.print( "{s} requested (new panes) {s}{s}\n" ++ "{s} effective (last spawn) {s}\n" ++ "{s} pending {s}\n", .{ setting.word, if (chosen.len == 0) context.compiled_default_shell else chosen, if (chosen.len == 0) " (default)" else "", setting.word, shown(state.shell.effective.get()), setting.word, onOff(state.shell.pending), }, ); }, .font => if (!setting.enabled(context.capabilities)) try out.print("{s} unsupported\n", .{setting.word}) 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" ++ "{s} effective {s}\n" ++ "{s} pending {s}\n" ++ "{s} effective size {d}.{d:0>2} {s}\n", .{ setting.word, shown(state.font.requested_name.get()), setting.word, shown(state.font.requested_path.get()), setting.word, shown(state.font.effective_name.get()), setting.word, onOff(state.font.pending), setting.word, state.font.effective_size_hundredths / 100, 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)) try out.print("{s} {d} (build-time only)\n", .{ setting.word, state.font.tagline_percent }) else try out.print("{s} {d}\n", .{ setting.word, state.font.tagline_percent }), .transition => { if (wrote_transition) continue; wrote_transition = true; if (!setting.enabled(context.capabilities)) try out.writeAll("Panel transition unsupported\n") else try out.print("Panel transition {s}\n", .{transitionSettingName(state.panel_transition)}); }, .scene => |effect| { if (!setting.enabled(context.capabilities)) { try out.print("{s} unsupported\n", .{setting.word}); continue; } const enabled = switch (effect) { inline else => |field| @field(state.scene_effects, @tagName(field)), }; try out.print("{s} {s}\n", .{ setting.word, onOff(enabled) }); }, }; if (context.startup_config_path) |path| try out.print("Startup config: {s}\n", .{path}) else try out.writeAll("Startup config: no per-user config path\n"); try out.print( "Platform: {s}\n" ++ "Compiled default shell: {s}\n", .{ context.platform, context.compiled_default_shell }, ); if (context.gui_shader_source_mode) |mode| try out.print("GUI shader source: {s}\n", .{mode}); if (context.hover_delay_frames) |frames| try out.print("Look hover delay: {d} frames\n", .{frames}) 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" { for (settings, 0..) |setting, i| { try std.testing.expect(setting.word.len > 0); for (settings[i + 1 ..]) |later| try std.testing.expect(!std.mem.eql(u8, setting.word, later.word)); } } test "panel and scene effects flip bare and are set by on and off" { var state: Runtime = .{}; const slide = findAction(.{ .transition = .slide }).?; const zoom = findAction(.{ .transition = .zoom }).?; try std.testing.expect(apply(&state, slide, "on")); try std.testing.expectEqual(layout.Transition.slide, state.panel_transition); try std.testing.expect(apply(&state, zoom, "off")); try std.testing.expectEqual(layout.Transition.slide, state.panel_transition); try std.testing.expect(apply(&state, slide, "on")); try std.testing.expectEqual(layout.Transition.slide, state.panel_transition); try std.testing.expect(apply(&state, slide, null)); try std.testing.expectEqual(layout.Transition.off, state.panel_transition); try std.testing.expect(!apply(&state, slide, "yes")); const scene = findAction(.{ .scene = std.enums.values(Scene)[0] }).?; try std.testing.expect(apply(&state, scene, "on")); try std.testing.expect(apply(&state, scene, "on")); switch (std.enums.values(Scene)[0]) { inline else => |f| try std.testing.expect(@field(state.scene_effects, @tagName(f))), } } test "a toggle flips when bare and sets when told on or off" { var state: Runtime = .{}; const verbose = find("Verbose").?; try std.testing.expect(state.verbose); // Bare, it flips: that is what a tagline word does. try std.testing.expect(apply(&state, verbose, null)); try std.testing.expect(!state.verbose); // Told, it SETS -- twice over, so the second call proves it is not // flipping past the value it was given. try std.testing.expect(apply(&state, verbose, "on")); try std.testing.expect(state.verbose); try std.testing.expect(apply(&state, verbose, " on\n")); try std.testing.expect(state.verbose); try std.testing.expect(apply(&state, verbose, "off")); try std.testing.expect(!state.verbose); try std.testing.expect(apply(&state, verbose, "off")); try std.testing.expect(!state.verbose); // And anything else is refused rather than silently flipping. try std.testing.expect(!apply(&state, verbose, "maybe")); try std.testing.expect(!state.verbose); } test "simple setting application mutates only its plain field" { var state: Runtime = .{}; try std.testing.expect(apply(&state, find("Colors").?, null)); try std.testing.expect(!state.colors); try std.testing.expect(apply(&state, find("Shell").?, " fish\n")); try std.testing.expectEqualStrings("fish", state.shell.requested.get()); try std.testing.expect(state.shell.pending); try std.testing.expect(apply(&state, find("PanelAscii").?, null)); try std.testing.expectEqual(layout.Transition.ascii, state.panel_transition); try std.testing.expect(apply(&state, find("PanelAscii").?, null)); try std.testing.expectEqual(layout.Transition.off, state.panel_transition); try std.testing.expect(apply(&state, find("Crt").?, null)); try std.testing.expect(state.scene_effects.crt); } test "tagline size validates before mutating live state" { const setting = find("TaglineSize").?; var state: Runtime = .{}; for ([_][]const u8{ "1", " 82\n", "100" }) |argument| { try std.testing.expect(apply(&state, setting, argument)); try std.testing.expectEqual(try std.fmt.parseInt(u8, std.mem.trim(u8, argument, " \t\r\n"), 10), state.font.tagline_percent); } state.font.tagline_percent = 67; for ([_]?[]const u8{ null, "", "0", "101", "-1", "50%", "999999999999999999999" }) |argument| { try std.testing.expect(!apply(&state, setting, argument)); try std.testing.expectEqual(@as(u8, 67), state.font.tagline_percent); } } test "window opacity defaults to opaque validates atomically and retries" { const setting = find("WindowOpacity").?; var state: Runtime = .{}; try std.testing.expectEqual(@as(u8, 100), state.window_opacity); try std.testing.expect(state.window_opacity_pending); try std.testing.expectEqual(Capability.window_opacity, setting.availability.?); for ([_][]const u8{ "0", " 85\n", "100" }, [_]u8{ 0, 85, 100 }) |argument, expected| { state.window_opacity_pending = false; try std.testing.expect(apply(&state, setting, argument)); try std.testing.expectEqual(expected, state.window_opacity); try std.testing.expect(state.window_opacity_pending); } state.window_opacity = 85; state.window_opacity_pending = false; for ([_]?[]const u8{ null, "", " \t\n", "101", "-1", "+85", "85%", "0.5", "85.0", "85 20", "8_5", "0x55", "999999999999999999999" }) |argument| { try std.testing.expect(!apply(&state, setting, argument)); try std.testing.expectEqual(@as(u8, 85), state.window_opacity); try std.testing.expect(!state.window_opacity_pending); } // Reissuing the same request still asks the host to apply it again. try std.testing.expect(apply(&state, setting, "85")); try std.testing.expect(state.window_opacity_pending); } test "window blur defaults off and validates without changing opacity" { const setting = find("WindowBlur").?; var state: Runtime = .{}; try std.testing.expectEqual(@as(u8, 0), state.window_blur); try std.testing.expectEqual(Capability.window_blur, setting.availability.?); for ([_][]const u8{ "0", " 40\n", "100" }, [_]u8{ 0, 40, 100 }) |argument, expected| { try std.testing.expect(apply(&state, setting, argument)); try std.testing.expectEqual(expected, state.window_blur); try std.testing.expectEqual(@as(u8, 100), state.window_opacity); } for ([_]?[]const u8{ null, "", " \t\n", "101", "-1", "+40", "40%", "0.5", "40 20", "4_0", "0x28", "999999999999999" }) |argument| { try std.testing.expect(!apply(&state, setting, argument)); try std.testing.expectEqual(@as(u8, 100), state.window_blur); } } test "font request tuple rejects atomically" { var state: Runtime = .{}; 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, 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" { var state: Runtime = .{}; // Every setting, every context line: the report outgrows 4K. var storage: [8192]u8 = undefined; const context: ReportContext = .{ .startup_config_path = "/tmp/pardes/init", .platform = "gui", .theme_name = "acme", .compiled_default_shell = "/bin/sh", .gui_shader_source_mode = "live GLSL compiled during this build", .hover_delay_frames = 18, .native_images = true, .capabilities = .{ .font_picker = true, .panel_transitions = true, .scene_shaders = true, .tagline_font_size = true, .window_opacity = true, .window_blur = true, .ligatures = true, }, .state = &state, }; for (settings) |setting| { switch (setting.action) { .theme, .font => continue, else => {}, } const argument: ?[]const u8 = switch (setting.action) { .shell => "fish", .tagline_size => "73", .window_opacity => "85", .window_blur => "40", .message_ms => "1500", .dump_dir => "~/dumps", else => null, }; try std.testing.expect(apply(&state, setting, argument)); var out: std.Io.Writer = .fixed(&storage); try writeReport(&out, context); const report = storage[0..out.end]; const expected = switch (setting.action) { .toggle => |field| switch (field) { .colors => "Colors off\n", .wrap => "Wrap off\n", .tree_context_tag_style => "TreeContextTagStyle 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", .verbose => "Verbose off\n", .message_animation => "MessageAnimation off\n", .ligatures => "Ligatures off\n", }, .shell => "Shell requested (new panes) fish\n", .tagline_size => "TaglineSize 73\n", .window_opacity => "WindowOpacity 85 (pending)\n", .window_blur => "WindowBlur 40\n", .dump_dir => "DumpDir ~/dumps\n", .message_ms => |which| switch (which) { .linger => "MessageLinger 1500\n", .fall => "MessageFall 1500\n", .dissolve => "MessageDissolve 1500\n", }, .transition => |transition| switch (transition) { .off => unreachable, .slide => "Panel transition PanelSlide\n", .zoom => "Panel transition PanelZoom\n", .dissolve => "Panel transition PanelDissolve\n", .ascii => "Panel transition PanelAscii\n", .vertical => "Panel transition PanelVertical\n", .edges => "Panel transition PanelEdges\n", .fall => "Panel transition PanelFall\n", .wave => "Panel transition PanelWave\n", .curtain => "Panel transition PanelCurtain\n", .scramble => "Panel transition PanelScramble\n", .typewriter => "Panel transition PanelType\n", }, .scene => |effect| switch (effect) { .crt => "Crt on\n", .ripple => "Ripple on\n", .glitch => "Glitch on\n", }, .theme, .font => unreachable, }; try std.testing.expect(std.mem.indexOf(u8, report, expected) != null); } 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; state.font.effective_size_unit = .points; state.font.tagline_percent = 82; var out: std.Io.Writer = .fixed(&storage); state.window_opacity_pending = false; try writeReport(&out, context); const report = storage[0..out.end]; for ([_][]const u8{ "Theme acme\n", "WindowOpacity 85\n", "Font requested size 18.50\n", "Font requested Wanted Mono\n", "Font requested path /fonts/wanted.ttf\n", "Font effective Effective Mono\n", "Font pending on\n", "Font effective size 13.75 points\n", "TaglineSize 82\n", "Startup config: /tmp/pardes/init\n", "Platform: gui\n", "Compiled default shell: /bin/sh\n", "GUI shader source: live GLSL compiled during this build\n", "Look hover delay: 18 frames\n", "Native images: on\n", }) |expected| try std.testing.expect(std.mem.indexOf(u8, report, expected) != null); var defaults: Runtime = .{}; var defaults_context = context; defaults_context.startup_config_path = null; defaults_context.platform = "tty"; defaults_context.gui_shader_source_mode = null; defaults_context.hover_delay_frames = null; defaults_context.native_images = false; defaults_context.capabilities = .{ .font_picker = false, .panel_transitions = true, .scene_shaders = false, .tagline_font_size = false, }; defaults_context.state = &defaults; out = .fixed(&storage); try writeReport(&out, defaults_context); const defaults_report = storage[0..out.end]; try std.testing.expect(std.mem.indexOf(u8, defaults_report, "WindowOpacity unsupported\n") != null); try std.testing.expect(!find("WindowOpacity").?.enabled(defaults_context.capabilities)); try std.testing.expect(std.mem.indexOf(u8, defaults_report, "WindowBlur unsupported\n") != null); try std.testing.expect(!find("WindowBlur").?.enabled(defaults_context.capabilities)); try std.testing.expect(find("WindowOpacity").?.enabled(context.capabilities)); try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Shell requested (new panes) /bin/sh (default)\n") != null); try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Shell effective (last spawn) (none)\n") != null); try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Shell pending on\n") != null); try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Startup config: no per-user config path\n") != null); try std.testing.expect(std.mem.indexOf(u8, defaults_report, "GUI shader source:") == null); try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Font unsupported\n") != null); try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Font requested") == null); try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Panel transition off\n") != null); try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Crt unsupported\n") != null); try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Ripple unsupported\n") != null); try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Glitch unsupported\n") != null); try std.testing.expect(std.mem.indexOf(u8, defaults_report, "TaglineSize unsupported\n") != null); try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Look hover delay: off\n") != null); try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Native images: off\n") != null); // A shell that does not shape text has no Ligatures setting to report. try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Ligatures") == null); defaults_context.platform = "web"; defaults_context.capabilities.panel_transitions = false; defaults_context.capabilities.tagline_font_size = true; out = .fixed(&storage); try writeReport(&out, defaults_context); const web_report = storage[0..out.end]; try std.testing.expect(std.mem.indexOf(u8, web_report, "Panel transition unsupported\n") != null); try std.testing.expect(std.mem.indexOf(u8, web_report, "TaglineSize 100 (build-time only)\n") != null); } }; pub const User = struct { const max_bytes = 1024 * 1024; pub const init_name = "init"; pub const builtin_themes_subdir = "themes/builtin"; // Relative XDG_CONFIG_HOME values are ignored. pub fn path(gpa: std.mem.Allocator, env: *const std.process.Environ.Map) !?[]u8 { if (builtin.os.tag == .windows) { if (env.get("LOCALAPPDATA")) |base| if (base.len != 0) return try std.fs.path.join(gpa, &.{ base, "pardes" }); if (env.get("USERPROFILE")) |home| if (home.len != 0) return try std.fs.path.join(gpa, &.{ home, "AppData", "Local", "pardes" }); return null; } if (env.get("XDG_CONFIG_HOME")) |base| if (base.len != 0 and std.fs.path.isAbsolute(base)) return try std.fs.path.join(gpa, &.{ base, "pardes" }); const home = env.get("HOME") orelse return null; if (home.len == 0) return null; if (builtin.os.tag == .macos) return try std.fs.path.join(gpa, &.{ home, "Library", "Application Support", "pardes" }); return try std.fs.path.join(gpa, &.{ home, ".config", "pardes" }); } // The caller's allocator owns these slices, including paths when init is absent. pub const Found = struct { dir: ?[]const u8 = null, path: ?[]const u8 = null, bytes: ?[]const u8 = null, }; pub fn load( io: std.Io, gpa: std.mem.Allocator, env: *const std.process.Environ.Map, ) Found { const config_dir = (path(gpa, env) catch return .{}) orelse return .{}; const config_path = std.fs.path.join(gpa, &.{ config_dir, init_name }) catch return .{ .dir = config_dir }; return .{ .dir = config_dir, .path = config_path, .bytes = std.Io.Dir.cwd().readFileAlloc(io, config_path, gpa, .limited(max_bytes)) catch null, }; } test "config path honors XDG and rejects a relative XDG directory" { if (builtin.os.tag == .windows) return; var env: std.process.Environ.Map = .init(std.testing.allocator); defer env.deinit(); try env.put("HOME", "/home/pardes-test"); try env.put("XDG_CONFIG_HOME", "/var/tmp/pardes-xdg"); const xdg = (try path(std.testing.allocator, &env)).?; defer std.testing.allocator.free(xdg); try std.testing.expectEqualStrings("/var/tmp/pardes-xdg/pardes", xdg); try env.put("XDG_CONFIG_HOME", "relative/config"); const fallback = (try path(std.testing.allocator, &env)).?; defer std.testing.allocator.free(fallback); const expected = if (builtin.os.tag == .macos) "/home/pardes-test/Library/Application Support/pardes" else "/home/pardes-test/.config/pardes"; try std.testing.expectEqualStrings(expected, fallback); } test "config loader reads init inside the config directory" { if (builtin.os.tag == .windows) return; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var base_buf: [std.fs.max_path_bytes]u8 = undefined; const base_len = try tmp.dir.realPath(std.testing.io, &base_buf); var env: std.process.Environ.Map = .init(std.testing.allocator); defer env.deinit(); try env.put("XDG_CONFIG_HOME", base_buf[0..base_len]); const missing = load(std.testing.io, std.testing.allocator, &env); defer std.testing.allocator.free(missing.dir.?); defer std.testing.allocator.free(missing.path.?); const expected_dir = try std.fs.path.join(std.testing.allocator, &.{ base_buf[0..base_len], "pardes" }); defer std.testing.allocator.free(expected_dir); const expected = try std.fs.path.join(std.testing.allocator, &.{ expected_dir, init_name }); defer std.testing.allocator.free(expected); try std.testing.expectEqualStrings(expected_dir, missing.dir.?); try std.testing.expectEqualStrings(expected, missing.path.?); try std.testing.expect(missing.bytes == null); const source = "Theme dark\nUnknown command\nTheme acme\n"; try tmp.dir.createDir(std.testing.io, "pardes", .default_dir); try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "pardes/init", .data = source }); const found = load(std.testing.io, std.testing.allocator, &env); defer std.testing.allocator.free(found.dir.?); defer std.testing.allocator.free(found.path.?); defer std.testing.allocator.free(found.bytes.?); try std.testing.expectEqualStrings(expected_dir, found.dir.?); try std.testing.expectEqualStrings(source, found.bytes.?); } // Replace generated theme files; preserve unrelated user files. pub fn dumpThemes( io: std.Io, gpa: std.mem.Allocator, config_dir: []const u8, theme_values: anytype, ) ![]u8 { const out_dir = try std.fs.path.join(gpa, &.{ config_dir, builtin_themes_subdir }); errdefer gpa.free(out_dir); try std.Io.Dir.cwd().createDirPath(io, out_dir); for (theme_values) |theme_value| { var encoded: std.Io.Writer.Allocating = .init(gpa); defer encoded.deinit(); try std.zon.stringify.serialize(theme_value, .{ .whitespace = true }, &encoded.writer); const filename = try std.fmt.allocPrint(gpa, "{s}.zon", .{theme_value.name}); defer gpa.free(filename); const output_path = try std.fs.path.join(gpa, &.{ out_dir, filename }); defer gpa.free(output_path); try std.Io.Dir.cwd().writeFile(io, .{ .sub_path = output_path, .data = encoded.written(), }); } return out_dir; } test "theme dump creates the builtin subdirectory and ZON files" { const io = std.testing.io; const gpa = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var base_buf: [std.fs.max_path_bytes]u8 = undefined; const base_len = try tmp.dir.realPath(io, &base_buf); const Sample = struct { name: []const u8, rgb: [3]u8 }; const samples = [_]Sample{ .{ .name = "one", .rgb = .{ 1, 2, 3 } }, .{ .name = "two", .rgb = .{ 4, 5, 6 } }, }; const output = try dumpThemes(io, gpa, base_buf[0..base_len], &samples); defer gpa.free(output); const expected = try std.fs.path.join(gpa, &.{ base_buf[0..base_len], builtin_themes_subdir }); defer gpa.free(expected); try std.testing.expectEqualStrings(expected, output); const one_path = try std.fs.path.join(gpa, &.{ output, "one.zon" }); defer gpa.free(one_path); const bytes = try std.Io.Dir.cwd().readFileAlloc(io, one_path, gpa, .limited(4096)); defer gpa.free(bytes); const source = try gpa.dupeZ(u8, bytes); defer gpa.free(source); const parsed = try std.zon.parse.fromSliceAlloc(Sample, gpa, source, null, .{}); defer std.zon.parse.free(gpa, parsed); try std.testing.expectEqualStrings("one", parsed.name); try std.testing.expectEqual([3]u8{ 1, 2, 3 }, parsed.rgb); } }; test { _ = Runtime; _ = User; }