771 lines
30 KiB
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());
|
|
}
|