zfin/src/tui/keybinds.zig

771 lines
30 KiB
Zig

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::<tab>` (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::<scope>,action::<action_name>,key::<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::<tab>` 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());
}