const std = @import("std"); const builtin = @import("builtin"); const layout = @import("layout.zig"); const animation = @import("animation.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: { @setEvalBranchQuota(10_000); // a check per builtin, and they grow 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, .Undo = null, .Edit = null, .Redo = 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", .Placement = "cp", .BootShell = null, .LookWord = null, .Debug = "td", .Msg = null, .Colors = "tc", .FocusTint = 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.lift) table.set(.CursorBlink, null); if (builtins.capabilities.lift) table.set(.Lift, null); table.set(.Motion, null); table.set(.InactiveDim, null); if (builtins.capabilities.lift) table.set(.GripWidth, 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(.Shader, null); table.set(.ShaderAnimation, null); } 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 }}; pub const keep_pipe: []const Chord = &.{.{ .cp = '$' }}; pub const macro_record: []const Chord = &.{.{ .cp = 'Q' }}; pub const macro_replay: []const Chord = &.{.{ .cp = 'q' }}; pub const repeat_insert: []const Chord = &.{.{ .cp = '.' }}; // 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 pipe_marker_keep = " $"; 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)); } /// The shell when no `Shell` is set: the user's login shell ($SHELL), as a /// terminal would start, else /bin/sh. An explicit `Shell` overrides it. pub fn defaultShell() []const u8 { if (comptime pardes.hosted) if (std.c.getenv("SHELL")) |s| { const shell = std.mem.span(s); // One that is not there, or not executable, is no shell: /bin/sh. if (shell.len > 0 and std.c.access(s, std.c.X_OK) == 0) return shell; }; return "/bin/sh"; } // 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 keep_marker = " Keep /"; pub const remove_marker = " Remove /"; 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 view_prefix_sticky = '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 = 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_middle: []const Chord = &.{.{ .cp = 'm' }}; 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_last_modification: []const Chord = &.{.{ .cp = '.' }}; pub const goto_word: []const Chord = &.{.{ .cp = 'w' }}; /// the jump labels' letters, helix's jump_label_alphabet pub const jump_label_alphabet = "abcdefghijklmnopqrstuvwxyz"; 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 goto_function: []const Chord = &.{.{ .cp = 'f' }}; pub const goto_class: []const Chord = &.{.{ .cp = 't' }}; pub const goto_parameter: []const Chord = &.{.{ .cp = 'a' }}; pub const goto_comment: []const Chord = &.{.{ .cp = 'c' }}; pub const goto_test: []const Chord = &.{.{ .cp = 'T' }}; pub const goto_entry: []const Chord = &.{.{ .cp = 'e' }}; pub const goto_xml_element: []const Chord = &.{.{ .cp = 'x' }}; 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 keep_regex: []const Chord = &.{.{ .cp = 'K' }}; pub const remove_regex: []const Chord = &.{.{ .cp = 'K', .alt = true }}; pub const ensure_forward: []const Chord = &.{.{ .cp = ':', .alt = true }}; // helix's tree-sitter selection; Alt-n stays the new terminal, so the next // sibling is Alt-right alone pub const expand_node: []const Chord = &.{ .{ .cp = 'o', .alt = true }, .{ .cp = Key.up, .alt = true } }; pub const shrink_node: []const Chord = &.{ .{ .cp = 'i', .alt = true }, .{ .cp = Key.down, .alt = true } }; pub const prev_sibling: []const Chord = &.{ .{ .cp = 'p', .alt = true }, .{ .cp = Key.left, .alt = true } }; pub const next_sibling: []const Chord = &.{.{ .cp = Key.right, .alt = true }}; pub const all_siblings: []const Chord = &.{.{ .cp = 'a', .alt = true }}; pub const all_children: []const Chord = &.{.{ .cp = 'I', .alt = true }}; pub const parent_node_end: []const Chord = &.{.{ .cp = 'e', .alt = true }}; pub const parent_node_start: []const Chord = &.{.{ .cp = 'b', .alt = true }}; 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 }}; // pardes's history is linear, so helix's earlier/later (Alt-u, Alt-U) are // undo and redo themselves pub const undo: []const Chord = &.{ .{ .cp = 'u' }, .{ .cp = 'u', .alt = true } }; pub const redo: []const Chord = &.{ .{ .cp = 'U' }, .{ .cp = 'U', .alt = true } }; 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 insert_checkpoint: []const Chord = &.{.{ .cp = 's', .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, 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, /// How the focused pane and the notices stand off the page (G1, /// docs/render-pipeline.md §9.1), or off. Opt-in until the focus lift's /// default is decided. Only a pixel shell draws it (`Capabilities.lift`). lift: LiftStyle = .off, /// Blends the unfocused panes' text toward each cell's own ground, /// percent (0 off): the focused pane wins by comparison, its tag tint /// untouched. Kept above min(each cell's contrast, 4.5). inactive_dim: u8 = 0, /// The grip's button and the scrollbar under it, one width, as a percent /// of the theme's `rail_px` (acme's 12px Scrollwid at a 17px tagline): /// a pixel shell's (docs/themes.md). grip_width: u16 = 150, /// How the fx track's animations move (animation.Motion): off for /// reduced motion. /// Smooth by default, tuned so arrivals land inside §8.1's 220 ms; the /// slower flavours are a choice. motion: animation.Motion.Flavour = .smooth, /// 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, /// The cursor blinks, a pixel shell's (docs/render-pipeline.md §5.4): /// solid while typing and for half a second after, eased at each edge, /// solid again after ten idle seconds. cursor_blink: 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: animation.Transition = .off, /// The post chain (docs/render-pipeline.md §6), in the order it runs. post: Post = .{}, /// When the chain redraws on its own, past the frames it post-processes: /// never, while the window has the focus, or always (ghostty's /// custom-shader-animation). shader_animation: ShaderAnimation = .on, /// Where a new pane goes (exec.placeNew): `acme`, acme's makenewwindow, /// or `pardes`, the rules pardes had before it. placement: Placement = .acme, /// A document dragged into the left column where a lone shell nobody /// has typed into stands: `keep` leaves the shell, `replace` closes it /// (exec.evictLonePristineTty). boot_shell: BootShell = .keep, /// A plain word looked at in a pane: `search`, acme's look3, selects its /// next place in that pane, wrapping; `list`, what pardes did before, /// lists every place in a +Search pane (look.zig, lookWord). look_word: LookWord = .search, pub const ShaderAnimation = enum { off, on, always }; pub const Placement = enum { acme, pardes }; pub const BootShell = enum { keep, replace }; pub const LookWord = enum { search, list }; /// A setting that is one of a few words, each an enum field of Runtime. pub const Choice = enum { placement, boot_shell, look_word }; /// shadow: a soft drop shadow. rim: a light hairline along the top of /// the focused pane's tag rows. auto: a shadow on a light page; on a /// dark one, where a shadow has nothing to darken, the other panes /// recede instead (InactiveDim, at `auto_dim` unless it is set). pub const LiftStyle = enum(u8) { off, shadow, rim, auto }; /// What `Lift auto` dims the unfocused panes by on a dark page when /// InactiveDim is 0: enough to read at a glance (the user's own init /// sets 35), not so much that they stop being worth reading. pub const auto_dim: u8 = 30; /// Each pass the bundled Crt or a Shadertoy file. pub const Post = struct { pub const max = 8; pub const Entry = struct { scene: ?Scene = null, path: Text(limits.host_path_cap) = .{}, /// A bundled pass's strength, 1 to `max_level`; 0 for a file. level: u8 = 0, }; pub const max_level = 3; /// What `on` and a bare word turn a bundled pass on at. pub const default_level = 2; entries: [max]Entry = @splat(.{}), len: u8 = 0, /// Bumped on every change: a shell compiles the chain again when it /// moves. generation: u32 = 0, pub fn list(post: *const Post) []const Entry { return post.entries[0..post.len]; } pub fn has(post: *const Post, scene: Scene) bool { return post.level(scene) != 0; } /// A bundled pass's level; 0 when it is not in the chain. pub fn level(post: *const Post, scene: Scene) u8 { for (post.list()) |entry| if (entry.scene == scene) return entry.level; return 0; } /// Sets a bundled pass's level: 0 takes it out, anything else puts it /// in at the end or changes it where it is. pub fn setLevel(post: *Post, scene: Scene, to: u8) bool { std.debug.assert(to <= max_level); if (to == 0) return post.set(scene, "", false); for (post.entries[0..post.len]) |*entry| if (entry.scene == scene) { if (entry.level != to) post.generation +%= 1; entry.level = to; return true; }; if (!post.set(scene, "", true)) return false; post.entries[post.len - 1].level = to; return true; } /// Puts the pass in the chain, at its end, or takes it out; `want` /// null flips it. False when the chain is full or the path too long. pub fn set(post: *Post, scene: ?Scene, path: []const u8, want: ?bool) bool { const at: ?usize = for (post.list(), 0..) |entry, i| { if (entry.scene == scene and std.mem.eql(u8, entry.path.get(), path)) break i; } else null; const on = want orelse (at == null); if (on == (at != null)) return true; post.generation +%= 1; if (at) |i| { std.mem.copyForwards(Entry, post.entries[i .. post.len - 1], post.entries[i + 1 .. post.len]); post.len -= 1; return true; } if (post.len == max) return false; post.entries[post.len] = .{ .scene = scene }; if (!post.entries[post.len].path.set(path)) return false; post.len += 1; return true; } pub fn clear(post: *Post) void { if (post.len == 0) return; post.len = 0; post.generation +%= 1; } }; pub fn toggleTransition(state: *Runtime, effect: animation.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, /// The shell draws soft shadows under what stands off the page. lift: 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, syntax_bold, verbose, message_animation, ligatures, cursor_blink }; /// The bundled post pass, a Shadertoy file in shaders/post/. pub const Scene = enum { crt }; pub const Action = union(enum) { toggle: Toggle, shell, theme, font, tagline_size, window_opacity, window_blur, message_ms: MessageTime, dump_dir, /// A lift style, `on` (auto) or `off`; bare flips auto and off. lift, /// A motion flavour. motion, /// A percentage, 0 through 100. inactive_dim, grip_width, transition: animation.Transition, scene: Scene, /// A Shadertoy file into the post chain or out of it; `off` empties /// it of files. shader, shader_animation, choice: Choice, }; 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 = "SyntaxBold", .action = .{ .toggle = .syntax_bold } }, .{ .word = "Verbose", .action = .{ .toggle = .verbose } }, .{ .word = "MessageAnimation", .action = .{ .toggle = .message_animation } }, .{ .word = "Ligatures", .action = .{ .toggle = .ligatures }, .availability = .ligatures }, .{ .word = "CursorBlink", .action = .{ .toggle = .cursor_blink }, .availability = .lift }, .{ .word = "Lift", .action = .lift, .availability = .lift }, .{ .word = "Motion", .action = .motion }, .{ .word = "InactiveDim", .action = .inactive_dim }, .{ .word = "GripWidth", .action = .grip_width, .availability = .lift }, .{ .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 = "Shader", .action = .shader, .availability = .scene_shaders }, .{ .word = "ShaderAnimation", .action = .shader_animation, .availability = .scene_shaders }, .{ .word = "Placement", .action = .{ .choice = .placement } }, .{ .word = "BootShell", .action = .{ .choice = .boot_shell } }, .{ .word = "LookWord", .action = .{ .choice = .look_word } }, }; 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, .shader, .shader_animation => 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. .lift, .grip_width => if (setting.availability != .lift) @compileError("Lift and GripWidth must use the lift capability (a pixel shell's)"), .toggle => |field| if (field == .ligatures) { if (setting.availability != .ligatures) @compileError("the ligatures toggle must use the ligatures capability"); } else if (field == .cursor_blink) { if (setting.availability != .lift) @compileError("CursorBlink must use the lift capability (a pixel shell's)"); } 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(animation.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 (actionCount(.shader) != 1 or actionCount(.shader_animation) != 1) @compileError("Shader and ShaderAnimation must each occur exactly once"); } 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 => { // Bare, the default again ($SHELL, else /bin/sh). const value = std.mem.trim(u8, argument orelse "", " \t\r\n"); if (!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| { // A level, 0 (off) to 3; `on` is the default level, `off` 0, // and bare flips between the two. const text = std.mem.trim(u8, argument orelse "", " \t\r\n"); const to: u8 = if (text.len == 0) (if (state.post.has(effect)) 0 else Post.default_level) else if (std.mem.eql(u8, text, "on")) Post.default_level else if (std.mem.eql(u8, text, "off")) 0 else std.fmt.parseInt(u8, text, 10) catch return false; if (to > Post.max_level) return false; return state.post.setLevel(effect, to); }, .shader => { const path = std.mem.trim(u8, argument orelse return false, " \t\r\n"); if (path.len == 0) return false; if (std.mem.eql(u8, path, "off")) { var i: usize = 0; while (i < state.post.len) { const entry = state.post.entries[i]; if (entry.scene == null) _ = state.post.set(null, entry.path.get(), false) else i += 1; } return true; } return state.post.set(null, path, null); }, .shader_animation => state.shader_animation = stepped(ShaderAnimation, state.shader_animation, argument) orelse return false, // Bare, it steps to the next word, as a tag word should. .choice => |which| switch (which) { inline else => |field| { const now = &@field(state, @tagName(field)); now.* = stepped(@TypeOf(now.*), now.*, argument) orelse return false; }, }, .lift => { const text = std.mem.trim(u8, argument orelse "", " \t\r\n"); state.lift = if (text.len == 0) (if (state.lift == .off) .auto else .off) else if (std.mem.eql(u8, text, "on")) .auto else std.meta.stringToEnum(LiftStyle, text) orelse return false; }, .motion => state.motion = stepped(animation.Motion.Flavour, state.motion, argument) orelse return false, .inactive_dim => { const text = std.mem.trim(u8, argument orelse return false, " \t\r\n%"); const percent = std.fmt.parseInt(u8, text, 10) catch return false; if (percent > 100) return false; state.inactive_dim = percent; }, .grip_width => { 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 < 50 or percent > 300) return false; state.grip_width = percent; }, .theme, .font => return false, } return true; } /// The values a setting that chooses among words takes, for a refusal to /// name; null for one that takes a number, a path or a name. Bare, each /// of these flips (a two-valued one) or steps to its next value. pub fn choices(action: Action) ?[]const u8 { const words = struct { fn of(comptime T: type) []const u8 { comptime var out: []const u8 = ""; inline for (std.meta.fieldNames(T), 0..) |name, i| out = out ++ (if (i > 0) ", " else "") ++ name; return out; } }; return switch (action) { .toggle, .transition => "on, off", .choice => |which| switch (which) { inline else => |field| comptime words.of(@FieldType(Runtime, @tagName(field))), }, .lift => "on, " ++ comptime words.of(LiftStyle), .motion => comptime words.of(animation.Motion.Flavour), .scene => std.fmt.comptimePrint("on, off, 0-{d}", .{Post.max_level}), .shader_animation => comptime words.of(ShaderAnimation), else => null, }; } /// The value a choice word names, or bare, the one after `now`; null for /// a word it does not take. fn stepped(comptime T: type, now: T, argument: ?[]const u8) ?T { const text = std.mem.trim(u8, argument orelse "", " \t\r\n"); if (text.len > 0) return std.meta.stringToEnum(T, text); const all = std.enums.values(T); const at = std.mem.indexOfScalar(T, all, now) orelse 0; return all[(at + 1) % all.len]; } /// 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: animation.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 level = state.post.level(effect); if (level == 0) try out.print("{s} off\n", .{setting.word}) else try out.print("{s} {d}\n", .{ setting.word, level }); }, .shader => { if (!setting.enabled(context.capabilities)) { try out.print("{s} unsupported\n", .{setting.word}); continue; } var files: usize = 0; for (state.post.list()) |entry| if (entry.scene == null) { try out.print("{s} {s}\n", .{ setting.word, entry.path.get() }); files += 1; }; if (files == 0) try out.print("{s} off\n", .{setting.word}); }, .shader_animation => if (setting.enabled(context.capabilities)) try out.print("{s} {s}\n", .{ setting.word, @tagName(state.shader_animation) }) else try out.print("{s} unsupported\n", .{setting.word}), .choice => |which| switch (which) { inline else => |field| try out.print("{s} {s}\n", .{ setting.word, @tagName(@field(state, @tagName(field))) }), }, .lift => if (setting.enabled(context.capabilities)) try out.print("{s} {s}\n", .{ setting.word, @tagName(state.lift) }) else try out.print("{s} unsupported\n", .{setting.word}), .motion => try out.print("{s} {s}\n", .{ setting.word, @tagName(state.motion) }), .inactive_dim => try out.print("{s} {d}\n", .{ setting.word, state.inactive_dim }), .grip_width => try out.print("{s} {d}\n", .{ setting.word, state.grip_width }), }; 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" ++ "Default shell ($SHELL, else /bin/sh): {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(animation.Transition.slide, state.panel_transition); try std.testing.expect(apply(&state, zoom, "off")); try std.testing.expectEqual(animation.Transition.slide, state.panel_transition); try std.testing.expect(apply(&state, slide, "on")); try std.testing.expectEqual(animation.Transition.slide, state.panel_transition); try std.testing.expect(apply(&state, slide, null)); try std.testing.expectEqual(animation.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")); try std.testing.expect(state.post.has(std.enums.values(Scene)[0])); } 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(animation.Transition.ascii, state.panel_transition); try std.testing.expect(apply(&state, find("PanelAscii").?, null)); try std.testing.expectEqual(animation.Transition.off, state.panel_transition); try std.testing.expect(apply(&state, find("Crt").?, null)); try std.testing.expect(state.post.has(.crt)); } test "the post chain runs in the order its passes were put in, and Shader off empties it of files" { var state: Runtime = .{}; try std.testing.expect(apply(&state, find("Shader").?, " ~/a.glsl\n")); try std.testing.expect(apply(&state, find("Crt").?, "on")); try std.testing.expect(apply(&state, find("Shader").?, "/b.glsl")); const chain = state.post.list(); try std.testing.expectEqual(@as(usize, 3), chain.len); try std.testing.expectEqualStrings("~/a.glsl", chain[0].path.get()); try std.testing.expectEqual(Scene.crt, chain[1].scene.?); try std.testing.expectEqualStrings("/b.glsl", chain[2].path.get()); const moved = state.post.generation; // A file named again leaves; the others keep their order. try std.testing.expect(apply(&state, find("Shader").?, "~/a.glsl")); try std.testing.expect(state.post.generation != moved); try std.testing.expectEqual(Scene.crt, state.post.list()[0].scene.?); try std.testing.expect(apply(&state, find("Shader").?, "off")); try std.testing.expectEqual(@as(usize, 1), state.post.len); try std.testing.expect(!apply(&state, find("Shader").?, null)); try std.testing.expect(apply(&state, find("ShaderAnimation").?, "always")); try std.testing.expectEqual(ShaderAnimation.always, state.shader_animation); try std.testing.expect(!apply(&state, find("ShaderAnimation").?, "sometimes")); } test "a bundled pass takes a level: on is the default, 0 and off take it out, and the old words still flip" { var state: Runtime = .{}; const crt = find("Crt").?; try std.testing.expect(apply(&state, crt, "on")); try std.testing.expectEqual(@as(u8, Post.default_level), state.post.level(.crt)); try std.testing.expect(apply(&state, find("Shader").?, "/after.glsl")); const moved = state.post.generation; try std.testing.expect(apply(&state, crt, "3")); try std.testing.expectEqual(@as(u8, 3), state.post.level(.crt)); // A new level keeps its place in the chain. try std.testing.expectEqual(Scene.crt, state.post.list()[0].scene.?); try std.testing.expect(state.post.generation != moved); try std.testing.expect(!apply(&state, crt, "4")); try std.testing.expect(!apply(&state, crt, "loud")); try std.testing.expect(apply(&state, crt, "0")); try std.testing.expect(!state.post.has(.crt)); try std.testing.expect(apply(&state, crt, null)); try std.testing.expectEqual(@as(u8, Post.default_level), state.post.level(.crt)); try std.testing.expect(apply(&state, crt, "off")); try std.testing.expectEqual(@as(u8, 0), state.post.level(.crt)); } test "Lift takes a style, on is auto, and bare flips it off and back to auto" { var state: Runtime = .{}; const lift = find("Lift").?; try std.testing.expect(apply(&state, lift, null)); try std.testing.expectEqual(LiftStyle.auto, state.lift); try std.testing.expect(apply(&state, lift, "rim")); try std.testing.expectEqual(LiftStyle.rim, state.lift); try std.testing.expect(apply(&state, lift, null)); try std.testing.expectEqual(LiftStyle.off, state.lift); try std.testing.expect(apply(&state, lift, "on")); try std.testing.expectEqual(LiftStyle.auto, state.lift); try std.testing.expect(!apply(&state, lift, "sparkle")); try std.testing.expectEqual(LiftStyle.auto, state.lift); } 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, .lift = 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", .shader => "~/crt.glsl", .shader_animation => "always", .choice => |which| switch (which) { .placement => "pardes", .boot_shell => "replace", .look_word => "list", }, .lift => "rim", .motion => "playful", .inactive_dim => "35", .grip_width => "200", 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", .syntax_bold => "SyntaxBold on\n", .verbose => "Verbose off\n", .message_animation => "MessageAnimation off\n", .ligatures => "Ligatures off\n", .cursor_blink => "CursorBlink 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 2\n", }, .shader => "Shader ~/crt.glsl\n", .shader_animation => "ShaderAnimation always\n", .choice => |which| switch (which) { .placement => "Placement pardes\n", .boot_shell => "BootShell replace\n", .look_word => "LookWord list\n", }, .lift => "Lift rim\n", .motion => "Motion playful\n", .inactive_dim => "InactiveDim 35\n", .grip_width => "GripWidth 200\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", "Default shell ($SHELL, else /bin/sh): /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, "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; }