const std = @import("std"); const vaxis = @import("vaxis"); const srf = @import("srf"); pub const Action = enum { quit, refresh, prev_tab, next_tab, tab_1, tab_2, tab_3, tab_4, tab_5, tab_6, tab_7, tab_8, scroll_down, scroll_up, scroll_top, scroll_bottom, page_down, page_up, select_next, select_prev, symbol_input, help, reload_portfolio, }; pub const KeyCombo = struct { codepoint: u21, mods: vaxis.Key.Modifiers = .{}, /// SRF custom parser. Used by `srf.Record.to(...)` to coerce a /// `key::ctrl+c` field into a `KeyCombo` value. Returns /// `error.CustomParseFailed` (via the srf-level wrapping) on /// invalid input. pub fn srfParse(val: []const u8) !KeyCombo { return parseKeyCombo(val) orelse error.InvalidKeyCombo; } }; pub const Binding = struct { action: Action, key: KeyCombo, }; /// A user-specified keybinding scoped to a particular tab. The /// `action_name` is the bare tag name from the tab's local /// `Action` enum (e.g. "collapse_all_calls" for scope "options"). /// Resolution to the typed enum happens in the dispatcher at the /// use site, where the tab's Action type is known. pub const ScopedBinding = struct { action_name: []const u8, key: KeyCombo, }; /// User overrides for a specific tab's keymap. When present in a /// loaded `KeyMap`, the dispatcher consults these instead of the /// tab module's `default_bindings`. Per the "user file replaces /// defaults" semantics: presence of any record with this scope /// fully replaces the tab's defaults (no merging). pub const TabOverrides = struct { /// Tag name matching a `Tab` enum variant / `tab_modules` /// registry field (e.g. "options", "history"). scope: []const u8, bindings: []const ScopedBinding, }; pub const KeyMap = struct { bindings: []const Binding, /// Per-tab user overrides loaded from keys.srf. Empty when no /// scoped records were present (or when using defaults). tab_overrides: []const TabOverrides = &.{}, /// Per-record parse warnings (e.g. unknown action name, /// malformed key string). Owned by `arena`. Empty when all /// records parsed cleanly OR when this keymap is the built-in /// defaults. Callers are expected to surface these to stderr. warnings: []const []const u8 = &.{}, arena: ?*std.heap.ArenaAllocator = null, pub fn deinit(self: *KeyMap) void { if (self.arena) |a| { const backing = a.child_allocator; a.deinit(); backing.destroy(a); } } pub fn matchAction(self: KeyMap, key: vaxis.Key) ?Action { for (self.bindings) |b| { if (key.matches(b.key.codepoint, b.key.mods)) return b.action; } return null; } /// Returns the user-override bindings for `scope`, or null if /// the scope has no overrides (caller should fall back to the /// tab module's `default_bindings`). pub fn tabOverridesFor(self: KeyMap, scope: []const u8) ?[]const ScopedBinding { for (self.tab_overrides) |to| { if (std.mem.eql(u8, to.scope, scope)) return to.bindings; } return null; } }; // ── Defaults ───────────────────────────────────────────────── pub const global_default_bindings = [_]Binding{ .{ .action = .quit, .key = .{ .codepoint = 'q' } }, .{ .action = .quit, .key = .{ .codepoint = 'c', .mods = .{ .ctrl = true } } }, .{ .action = .refresh, .key = .{ .codepoint = 'r' } }, .{ .action = .refresh, .key = .{ .codepoint = vaxis.Key.f5 } }, .{ .action = .prev_tab, .key = .{ .codepoint = 'h' } }, .{ .action = .prev_tab, .key = .{ .codepoint = vaxis.Key.left } }, .{ .action = .prev_tab, .key = .{ .codepoint = vaxis.Key.tab, .mods = .{ .shift = true } } }, .{ .action = .next_tab, .key = .{ .codepoint = 'l' } }, .{ .action = .next_tab, .key = .{ .codepoint = vaxis.Key.right } }, .{ .action = .next_tab, .key = .{ .codepoint = vaxis.Key.tab } }, .{ .action = .tab_1, .key = .{ .codepoint = '1' } }, .{ .action = .tab_2, .key = .{ .codepoint = '2' } }, .{ .action = .tab_3, .key = .{ .codepoint = '3' } }, .{ .action = .tab_4, .key = .{ .codepoint = '4' } }, .{ .action = .tab_5, .key = .{ .codepoint = '5' } }, .{ .action = .tab_6, .key = .{ .codepoint = '6' } }, .{ .action = .tab_7, .key = .{ .codepoint = '7' } }, .{ .action = .tab_8, .key = .{ .codepoint = '8' } }, .{ .action = .scroll_down, .key = .{ .codepoint = 'd', .mods = .{ .ctrl = true } } }, .{ .action = .scroll_up, .key = .{ .codepoint = 'u', .mods = .{ .ctrl = true } } }, .{ .action = .scroll_top, .key = .{ .codepoint = 'g' } }, .{ .action = .scroll_bottom, .key = .{ .codepoint = 'G' } }, .{ .action = .page_down, .key = .{ .codepoint = vaxis.Key.page_down } }, .{ .action = .page_down, .key = .{ .codepoint = 'f', .mods = .{ .ctrl = true } } }, .{ .action = .page_up, .key = .{ .codepoint = vaxis.Key.page_up } }, .{ .action = .page_up, .key = .{ .codepoint = 'b', .mods = .{ .ctrl = true } } }, .{ .action = .select_next, .key = .{ .codepoint = 'j' } }, .{ .action = .select_next, .key = .{ .codepoint = vaxis.Key.down } }, .{ .action = .select_prev, .key = .{ .codepoint = 'k' } }, .{ .action = .select_prev, .key = .{ .codepoint = vaxis.Key.up } }, .{ .action = .symbol_input, .key = .{ .codepoint = '/' } }, .{ .action = .help, .key = .{ .codepoint = '?' } }, .{ .action = .reload_portfolio, .key = .{ .codepoint = 'R' } }, }; pub fn defaults() KeyMap { return .{ .bindings = &global_default_bindings }; } /// Display labels for each global Action variant. Used by the help /// overlay and status-line hint to render human-readable names /// alongside the resolved key bindings. Parallel in shape to each /// tab module's own `tab.action_labels`. pub const action_labels = std.enums.EnumArray(Action, []const u8).init(.{ .quit = "Quit", .refresh = "Refresh", .prev_tab = "Previous tab", .next_tab = "Next tab", .tab_1 = "Tab 1", .tab_2 = "Tab 2", .tab_3 = "Tab 3", .tab_4 = "Tab 4", .tab_5 = "Tab 5", .tab_6 = "Tab 6", .tab_7 = "Tab 7", .tab_8 = "Tab 8", .scroll_down = "Half page down", .scroll_up = "Half page up", .scroll_top = "Scroll to top", .scroll_bottom = "Scroll to bottom", .page_down = "Page down", .page_up = "Page up", .select_next = "Select next", .select_prev = "Select previous", .symbol_input = "Symbol input", .help = "Help", .reload_portfolio = "Reload portfolio", }); // ── SRF serialization ──────────────────────────────────────── const special_key_names = [_]struct { name: []const u8, cp: u21 }{ .{ .name = "tab", .cp = vaxis.Key.tab }, .{ .name = "enter", .cp = vaxis.Key.enter }, .{ .name = "escape", .cp = vaxis.Key.escape }, .{ .name = "space", .cp = vaxis.Key.space }, .{ .name = "backspace", .cp = vaxis.Key.backspace }, .{ .name = "insert", .cp = vaxis.Key.insert }, .{ .name = "delete", .cp = vaxis.Key.delete }, .{ .name = "left", .cp = vaxis.Key.left }, .{ .name = "right", .cp = vaxis.Key.right }, .{ .name = "up", .cp = vaxis.Key.up }, .{ .name = "down", .cp = vaxis.Key.down }, .{ .name = "page_up", .cp = vaxis.Key.page_up }, .{ .name = "page_down", .cp = vaxis.Key.page_down }, .{ .name = "home", .cp = vaxis.Key.home }, .{ .name = "end", .cp = vaxis.Key.end }, .{ .name = "F1", .cp = vaxis.Key.f1 }, .{ .name = "F2", .cp = vaxis.Key.f2 }, .{ .name = "F3", .cp = vaxis.Key.f3 }, .{ .name = "F4", .cp = vaxis.Key.f4 }, .{ .name = "F5", .cp = vaxis.Key.f5 }, .{ .name = "F6", .cp = vaxis.Key.f6 }, .{ .name = "F7", .cp = vaxis.Key.f7 }, .{ .name = "F8", .cp = vaxis.Key.f8 }, .{ .name = "F9", .cp = vaxis.Key.f9 }, .{ .name = "F10", .cp = vaxis.Key.f10 }, .{ .name = "F11", .cp = vaxis.Key.f11 }, .{ .name = "F12", .cp = vaxis.Key.f12 }, }; fn codepointToName(cp: u21) ?[]const u8 { for (special_key_names) |entry| { if (entry.cp == cp) return entry.name; } return null; } fn nameToCodepoint(name: []const u8) ?u21 { // Check our table first (case-insensitive for F-keys) for (special_key_names) |entry| { if (std.ascii.eqlIgnoreCase(entry.name, name)) return entry.cp; } // Fall back to vaxis name_map (lowercase) var lower_buf: [32]u8 = undefined; const lower = toLower(name, &lower_buf) orelse return null; return vaxis.Key.name_map.get(lower); } fn toLower(s: []const u8, buf: []u8) ?[]const u8 { if (s.len > buf.len) return null; for (s, 0..) |c, i| { buf[i] = std.ascii.toLower(c); } return buf[0..s.len]; } pub fn formatKeyCombo(combo: KeyCombo, buf: []u8) ?[]const u8 { var pos: usize = 0; if (combo.mods.ctrl) { const prefix = "ctrl+"; if (pos + prefix.len > buf.len) return null; @memcpy(buf[pos..][0..prefix.len], prefix); pos += prefix.len; } if (combo.mods.alt) { const prefix = "alt+"; if (pos + prefix.len > buf.len) return null; @memcpy(buf[pos..][0..prefix.len], prefix); pos += prefix.len; } if (combo.mods.shift) { const prefix = "shift+"; if (pos + prefix.len > buf.len) return null; @memcpy(buf[pos..][0..prefix.len], prefix); pos += prefix.len; } if (codepointToName(combo.codepoint)) |name| { if (pos + name.len > buf.len) return null; @memcpy(buf[pos..][0..name.len], name); pos += name.len; } else if (combo.codepoint >= 0x20 and combo.codepoint < 0x7f) { if (pos + 1 > buf.len) return null; buf[pos] = @intCast(combo.codepoint); pos += 1; } else { return null; } return buf[0..pos]; } fn parseKeyCombo(key_str: []const u8) ?KeyCombo { var mods: vaxis.Key.Modifiers = .{}; var rest = key_str; // Parse modifier prefixes while (true) { if (rest.len > 5 and std.ascii.eqlIgnoreCase(rest[0..5], "ctrl+")) { mods.ctrl = true; rest = rest[5..]; } else if (rest.len > 4 and std.ascii.eqlIgnoreCase(rest[0..4], "alt+")) { mods.alt = true; rest = rest[4..]; } else if (rest.len > 6 and std.ascii.eqlIgnoreCase(rest[0..6], "shift+")) { mods.shift = true; rest = rest[6..]; } else break; } if (rest.len == 0) return null; // Single printable character if (rest.len == 1 and rest[0] >= 0x20 and rest[0] < 0x7f) { return .{ .codepoint = rest[0], .mods = mods }; } // Named key if (nameToCodepoint(rest)) |cp| { return .{ .codepoint = cp, .mods = mods }; } return null; } /// Print default keybindings in SRF format to stdout. /// Write the SRF preamble (header line, file-purpose comments, /// format documentation) to `out`. Used by the `--default-keys` /// CLI to produce a self-documenting keys.srf file. Caller is /// responsible for writing the binding records and flushing. pub fn printDefaultsHeader(out: *std.Io.Writer) !void { try out.writeAll("#!srfv1\n"); try out.writeAll("# zfin TUI keybindings\n"); try out.writeAll("# This file is the sole source of keybindings when present.\n"); try out.writeAll("# If this file is removed, built-in defaults are used.\n"); try out.writeAll("# Regenerate: zfin interactive --default-keys > ~/.config/zfin/keys.srf\n"); try out.writeAll("#\n"); try out.writeAll("# Format: action::ACTION_NAME,key::KEY_STRING[,scope::SCOPE]\n"); try out.writeAll("# Modifiers: ctrl+, alt+, shift+ (e.g. ctrl+c)\n"); try out.writeAll("# Special keys: tab, enter, escape, space, backspace,\n"); try out.writeAll("# left, right, up, down, page_up, page_down, home, end,\n"); try out.writeAll("# F1-F12, insert, delete\n"); try out.writeAll("# Multiple lines with the same action = multiple bindings.\n"); try out.writeAll("#\n"); try out.writeAll("# scope:: is optional. Omitted (or `scope::global`) = global\n"); try out.writeAll("# binding (this section). `scope::` (e.g. scope::options,\n"); try out.writeAll("# scope::history) = tab-local binding; the action name then\n"); try out.writeAll("# refers to that tab's local Action enum. Tab-local bindings\n"); try out.writeAll("# cannot use a key that's globally bound — zfin will refuse\n"); try out.writeAll("# to start if you create that conflict.\n"); } /// Write a section comment header to `out`. Used to label the /// global vs. per-tab sections in the generated keys.srf. pub fn printSectionHeader(out: *std.Io.Writer, name: []const u8) !void { try out.writeAll("\n# ── "); try out.writeAll(name); try out.writeAll(" ──\n"); } /// Write the global default bindings to `out`, one per line. pub fn printGlobalBindings(out: *std.Io.Writer) !void { for (global_default_bindings) |b| { var key_buf: [32]u8 = undefined; const key_str = formatKeyCombo(b.key, &key_buf) orelse continue; try out.print("action::{s},key::{s}\n", .{ @tagName(b.action), key_str }); } } /// Write a single scoped binding record to `out` in the form: /// `scope::,action::,key::`. pub fn printScopedBinding( out: *std.Io.Writer, scope: []const u8, action_name: []const u8, key: KeyCombo, ) !void { var key_buf: [32]u8 = undefined; const key_str = formatKeyCombo(key, &key_buf) orelse return; try out.print("scope::{s},action::{s},key::{s}\n", .{ scope, action_name, key_str }); } /// Print the full default keymap (globals only — this entry point /// pre-dates the per-tab `default_bindings`). Maintained for /// backward compatibility with anything calling it directly. New /// callers should prefer the per-piece helpers above and orchestrate /// the tab sections themselves; `src/tui.zig`'s `--default-keys` /// handler does this. pub fn printDefaults(io: std.Io) !void { var buf: [4096]u8 = undefined; var writer = std.Io.File.stdout().writer(io, &buf); const out = &writer.interface; try printDefaultsHeader(out); try printSectionHeader(out, "Global"); try printGlobalBindings(out); try out.flush(); } // ── SRF loading ────────────────────────────────────────────── fn parseAction(name: []const u8) ?Action { return std.meta.stringToEnum(Action, name); } /// Errors from `loadFromData` that the caller should surface to /// the user (stderr) rather than silently fall back to defaults. pub const LoadError = error{ /// A user record under `scope::` binds a key that is also /// bound in the global keymap. Globals always win, so the /// scoped binding would be dead. The TUI refuses to start. TabBindingShadowsGlobal, }; /// Outcome of `loadFromData`. Either a successfully-built KeyMap, /// a hard error the caller should surface (stderr + refuse to /// start), or a soft `null` result (file unparseable) that means /// "fall back to defaults silently" — the existing behavior. /// /// Successful results may include `warnings` describing per-record /// problems (e.g. unknown action name, malformed key string). Each /// warning is a string allocated in the keymap's arena. The caller /// is expected to surface these to stderr; they aren't fatal /// because the rest of the file is usable. pub const LoadOutcome = union(enum) { keymap: KeyMap, err: LoadError, fallback, }; /// One record from keys.srf, decoded via `srf.Record.to`. /// Field names match the SRF field names; `scope` is optional and /// defaults to null (= global). const RawRecord = struct { action: []const u8, key: KeyCombo, scope: ?[]const u8 = null, }; /// Load keybindings from an SRF file. Returns null if the file doesn't exist /// or can't be parsed. On success, the caller owns the returned KeyMap and /// must call deinit(). /// /// For richer error reporting (specifically the "tab binding /// shadows a global" case), use `loadFromFileChecked` which /// returns a `LoadOutcome`. pub fn loadFromFile(io: std.Io, allocator: std.mem.Allocator, path: []const u8) ?KeyMap { return switch (loadFromFileChecked(io, allocator, path)) { .keymap => |km| km, .err, .fallback => null, }; } /// Load with detailed outcome. Used by the TUI startup path so /// `TabBindingShadowsGlobal` can be surfaced as a hard error /// (stderr + exit) rather than silently falling back to defaults. pub fn loadFromFileChecked(io: std.Io, allocator: std.mem.Allocator, path: []const u8) LoadOutcome { const data = std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(64 * 1024)) catch return .fallback; defer allocator.free(data); return loadFromDataChecked(allocator, data); } pub fn loadFromData(allocator: std.mem.Allocator, data: []const u8) ?KeyMap { return switch (loadFromDataChecked(allocator, data)) { .keymap => |km| km, .err, .fallback => null, }; } pub fn loadFromDataChecked(allocator: std.mem.Allocator, data: []const u8) LoadOutcome { var reader = std.Io.Reader.fixed(data); const parsed = srf.parse(&reader, allocator, .{}) catch return .fallback; // Don't deinit `parsed` until the end — its arena owns the // string slices we'll borrow into the returned KeyMap. We // transfer ownership to the KeyMap's arena instead. // Move parsed.arena into our own KeyMap so it outlives this // call. The `Parsed` struct holds the arena by pointer; we // claim it directly. const arena = parsed.arena; errdefer { arena.deinit(); allocator.destroy(arena); } const aa = arena.allocator(); var globals = std.ArrayList(Binding).empty; var scopes = std.ArrayList(ScopeBuilder).empty; var warnings = std.ArrayList([]const u8).empty; for (parsed.records, 0..) |record, idx| { const raw = record.to(RawRecord) catch |err| { // Per-record parse failure (missing field, bad key // string, unknown action). Don't drop the whole file — // skip the record and warn the user. Record index is // 0-based; the user-facing message uses 1-based. const msg = std.fmt.allocPrint( aa, "keys.srf: record {d}: {s}; binding skipped", .{ idx + 1, @errorName(err) }, ) catch return .fallback; warnings.append(aa, msg) catch return .fallback; continue; }; const is_global = raw.scope == null or std.mem.eql(u8, raw.scope.?, "global"); if (is_global) { const a = parseAction(raw.action) orelse { const msg = std.fmt.allocPrint( aa, "keys.srf: record {d}: unknown global action `{s}`; binding skipped", .{ idx + 1, raw.action }, ) catch return .fallback; warnings.append(aa, msg) catch return .fallback; continue; }; globals.append(aa, .{ .action = a, .key = raw.key }) catch return .fallback; } else { // Find or create the scope bucket. Action-name validation // against the tab's local `Action` enum happens in the // dispatcher at use-time — keybinds.zig doesn't know // about tab modules. const scope_name = raw.scope.?; var bucket: ?*ScopeBuilder = null; for (scopes.items) |*sb| { if (std.mem.eql(u8, sb.scope, scope_name)) { bucket = sb; break; } } if (bucket == null) { scopes.append(aa, .{ .scope = scope_name, .bindings = std.ArrayList(ScopedBinding).empty, }) catch return .fallback; bucket = &scopes.items[scopes.items.len - 1]; } bucket.?.bindings.append(aa, .{ .action_name = raw.action, .key = raw.key, }) catch return .fallback; } } // Conflict check: any tab-scoped binding whose key collides // with the (post-load) global keymap = hard error. Caller will // print the conflict to stderr + refuse to start the TUI. for (scopes.items) |sb| { for (sb.bindings.items) |b| { for (globals.items) |gb| { if (b.key.codepoint == gb.key.codepoint and std.meta.eql(b.key.mods, gb.key.mods)) { arena.deinit(); allocator.destroy(arena); return .{ .err = error.TabBindingShadowsGlobal }; } } } } // Materialize tab overrides into owned slices. var tab_overrides = std.ArrayList(TabOverrides).empty; for (scopes.items) |*sb| { const slice = sb.bindings.toOwnedSlice(aa) catch return .fallback; tab_overrides.append(aa, .{ .scope = sb.scope, .bindings = slice, }) catch return .fallback; } return .{ .keymap = .{ .bindings = globals.toOwnedSlice(aa) catch return .fallback, .tab_overrides = tab_overrides.toOwnedSlice(aa) catch return .fallback, .warnings = warnings.toOwnedSlice(aa) catch return .fallback, .arena = arena, } }; } /// Internal scratch type used by `loadFromDataChecked` to bucket /// scoped bindings during parsing. const ScopeBuilder = struct { scope: []const u8, bindings: std.ArrayList(ScopedBinding), }; // ── Tests ──────────────────────────────────────────────────── test "parseKeyCombo single char" { const combo = parseKeyCombo("q").?; try std.testing.expectEqual(@as(u21, 'q'), combo.codepoint); try std.testing.expect(!combo.mods.ctrl); } test "parseKeyCombo ctrl modifier" { const combo = parseKeyCombo("ctrl+c").?; try std.testing.expectEqual(@as(u21, 'c'), combo.codepoint); try std.testing.expect(combo.mods.ctrl); } test "parseKeyCombo special key" { const combo = parseKeyCombo("F5").?; try std.testing.expectEqual(vaxis.Key.f5, combo.codepoint); } test "parseKeyCombo named key" { const combo = parseKeyCombo("tab").?; try std.testing.expectEqual(vaxis.Key.tab, combo.codepoint); } test "formatKeyCombo roundtrip" { var buf: [32]u8 = undefined; const combo = KeyCombo{ .codepoint = 'c', .mods = .{ .ctrl = true } }; const str = formatKeyCombo(combo, &buf).?; try std.testing.expectEqualStrings("ctrl+c", str); const parsed = parseKeyCombo(str).?; try std.testing.expectEqual(combo.codepoint, parsed.codepoint); try std.testing.expect(parsed.mods.ctrl); } test "parseAction" { try std.testing.expectEqual(Action.quit, parseAction("quit").?); try std.testing.expectEqual(Action.refresh, parseAction("refresh").?); try std.testing.expect(parseAction("nonexistent") == null); } test "loadFromData basic" { const data = \\#!srfv1 \\action::quit,key::q \\action::quit,key::ctrl+c \\action::refresh,key::F5 ; var km = loadFromData(std.testing.allocator, data) orelse return error.ParseFailed; defer km.deinit(); try std.testing.expectEqual(@as(usize, 3), km.bindings.len); try std.testing.expectEqual(Action.quit, km.bindings[0].action); try std.testing.expectEqual(Action.refresh, km.bindings[2].action); try std.testing.expectEqual(@as(usize, 0), km.tab_overrides.len); } test "loadFromData scoped binding" { const data = \\#!srfv1 \\action::quit,key::q \\scope::options,action::collapse_all_calls,key::C ; var km = loadFromData(std.testing.allocator, data) orelse return error.ParseFailed; defer km.deinit(); try std.testing.expectEqual(@as(usize, 1), km.bindings.len); try std.testing.expectEqual(@as(usize, 1), km.tab_overrides.len); try std.testing.expectEqualStrings("options", km.tab_overrides[0].scope); try std.testing.expectEqual(@as(usize, 1), km.tab_overrides[0].bindings.len); try std.testing.expectEqualStrings("collapse_all_calls", km.tab_overrides[0].bindings[0].action_name); try std.testing.expectEqual(@as(u21, 'C'), km.tab_overrides[0].bindings[0].key.codepoint); } test "loadFromData scope::global is treated as global" { const data = \\#!srfv1 \\scope::global,action::quit,key::q ; var km = loadFromData(std.testing.allocator, data) orelse return error.ParseFailed; defer km.deinit(); try std.testing.expectEqual(@as(usize, 1), km.bindings.len); try std.testing.expectEqual(Action.quit, km.bindings[0].action); try std.testing.expectEqual(@as(usize, 0), km.tab_overrides.len); } test "loadFromDataChecked rejects tab binding shadowing global" { // Both `q` (global quit) and a scoped binding for the same key. const data = \\#!srfv1 \\action::quit,key::q \\scope::options,action::collapse_all_calls,key::q ; const outcome = loadFromDataChecked(std.testing.allocator, data); switch (outcome) { .err => |e| try std.testing.expectEqual(LoadError.TabBindingShadowsGlobal, e), else => return error.ExpectedConflictError, } } test "loadFromData scoped bindings group by scope" { const data = \\#!srfv1 \\scope::options,action::collapse_all_calls,key::C \\scope::options,action::collapse_all_puts,key::P \\scope::history,action::metric_next,key::M ; var km = loadFromData(std.testing.allocator, data) orelse return error.ParseFailed; defer km.deinit(); try std.testing.expectEqual(@as(usize, 2), km.tab_overrides.len); // Order matches first-occurrence in input. try std.testing.expectEqualStrings("options", km.tab_overrides[0].scope); try std.testing.expectEqual(@as(usize, 2), km.tab_overrides[0].bindings.len); try std.testing.expectEqualStrings("history", km.tab_overrides[1].scope); try std.testing.expectEqual(@as(usize, 1), km.tab_overrides[1].bindings.len); } test "loadFromData unknown action emits warning" { const data = \\#!srfv1 \\action::quit,key::q \\action::not_a_real_action,key::z ; var km = loadFromData(std.testing.allocator, data) orelse return error.ParseFailed; defer km.deinit(); try std.testing.expectEqual(@as(usize, 1), km.bindings.len); try std.testing.expectEqual(@as(usize, 1), km.warnings.len); try std.testing.expect(std.mem.indexOf(u8, km.warnings[0], "not_a_real_action") != null); } // NOTE: there's no `loadFromData malformed key emits warning` test // here. That path goes through srf's `coerce` → `KeyCombo.srfParse`, // and srf logs at `log.err` when our srfParse returns an error — // which the Zig 0.16 test runner treats as a test failure even when // the test itself passes its asserts. The malformed-key parsing // path is covered directly by the `parseKeyCombo` tests above. test "tabOverridesFor lookup" { const data = \\#!srfv1 \\scope::options,action::collapse_all_calls,key::C ; var km = loadFromData(std.testing.allocator, data) orelse return error.ParseFailed; defer km.deinit(); try std.testing.expect(km.tabOverridesFor("options") != null); try std.testing.expect(km.tabOverridesFor("history") == null); } test "defaults returns valid keymap" { const km = defaults(); try std.testing.expect(km.bindings.len > 0); // Verify quit is in there var found_quit = false; for (km.bindings) |b| { if (b.action == .quit) found_quit = true; } try std.testing.expect(found_quit); } test "action_labels: every Action variant has a non-empty label" { inline for (std.meta.fields(Action)) |f| { const action: Action = @enumFromInt(f.value); const label = action_labels.get(action); try std.testing.expect(label.len > 0); } } test "printScopedBinding writes scope, action, key in expected order" { var aw: std.Io.Writer.Allocating = .init(std.testing.allocator); defer aw.deinit(); const key: KeyCombo = .{ .codepoint = 'a', .mods = .{} }; try printScopedBinding(&aw.writer, "history", "compare_select", key); try std.testing.expectEqualStrings( "scope::history,action::compare_select,key::a\n", aw.written(), ); } test "printScopedBinding renders modifier keys" { var aw: std.Io.Writer.Allocating = .init(std.testing.allocator); defer aw.deinit(); const key: KeyCombo = .{ .codepoint = 'c', .mods = .{ .ctrl = true } }; try printScopedBinding(&aw.writer, "options", "filter_3", key); try std.testing.expectEqualStrings( "scope::options,action::filter_3,key::ctrl+c\n", aw.written(), ); } test "printGlobalBindings emits at least quit and refresh" { var aw: std.Io.Writer.Allocating = .init(std.testing.allocator); defer aw.deinit(); try printGlobalBindings(&aw.writer); const out = aw.written(); try std.testing.expect(std.mem.indexOf(u8, out, "action::quit,key::q") != null); try std.testing.expect(std.mem.indexOf(u8, out, "action::refresh,key::r") != null); // Globals never carry a scope:: prefix. try std.testing.expect(std.mem.indexOf(u8, out, "scope::") == null); } test "printSectionHeader writes a comment header line" { var aw: std.Io.Writer.Allocating = .init(std.testing.allocator); defer aw.deinit(); try printSectionHeader(&aw.writer, "Tab: portfolio"); try std.testing.expectEqualStrings("\n# ── Tab: portfolio ──\n", aw.written()); }