From 0c3ddd1ffc363379cc61c1c2972e6e6721101cb9 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Fri, 15 May 2026 15:28:50 -0700 Subject: [PATCH] get tab-scoped keys into the default keys output --- src/tui.zig | 135 +++++++++++++++++++++++++++++++++++--- src/tui/keybinds.zig | 88 +++++++++++++++++++++++-- src/tui/tab_framework.zig | 10 +-- 3 files changed, 213 insertions(+), 20 deletions(-) diff --git a/src/tui.zig b/src/tui.zig index f655566..0b28e56 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -1023,15 +1023,8 @@ pub const App = struct { // No overrides — read from the tab's default_bindings. for (Module.tab.default_bindings) |binding| { if (!std.mem.eql(u8, @tagName(binding.action), action_tag_name)) continue; - // Tab modules use `tab_framework.KeyCombo`, which is - // structurally identical to but type-distinct from - // `keybinds.KeyCombo`. Repack for `formatKeyCombo`. - const combo: keybinds.KeyCombo = .{ - .codepoint = binding.key.codepoint, - .mods = binding.key.mods, - }; var key_buf: [32]u8 = undefined; - const s = keybinds.formatKeyCombo(combo, &key_buf) orelse continue; + const s = keybinds.formatKeyCombo(binding.key, &key_buf) orelse continue; try out.append(arena, try arena.dupe(u8, s)); } return out.toOwnedSlice(arena); @@ -2265,6 +2258,63 @@ comptime { _ = tab_modules.projections; } +/// Write the full default keymap to `out` in keys.srf format, +/// covering both the global section and each tab's local bindings. +/// +/// Output shape: +/// +/// ``` +/// #!srfv1 +/// # ... preamble + format docs ... +/// +/// # ── Global ── +/// action::quit,key::q +/// ... +/// +/// # ── Tab: portfolio ── +/// scope::portfolio,action::toggle_account_picker,key::a +/// ... +/// ``` +/// +/// Per-tab sections are emitted in `tab_modules` declaration order +/// and only when the tab has at least one default binding (skipping +/// empty sections keeps the file compact). +/// +/// Caller is responsible for flushing. +fn writeDefaultKeys(out: *std.Io.Writer) !void { + try keybinds.printDefaultsHeader(out); + + try keybinds.printSectionHeader(out, "Global"); + try keybinds.printGlobalBindings(out); + + inline for (std.meta.fields(@TypeOf(tab_modules))) |field| { + const Module = @field(tab_modules, field.name); + if (Module.tab.default_bindings.len == 0) continue; + + const heading = "Tab: " ++ field.name; + try keybinds.printSectionHeader(out, heading); + + for (Module.tab.default_bindings) |binding| { + try keybinds.printScopedBinding( + out, + field.name, + @tagName(binding.action), + binding.key, + ); + } + } +} + +/// CLI entry point for `--default-keys`. Writes the full default +/// keymap to stdout. See `writeDefaultKeys` for output format. +fn printDefaultKeys(io: std.Io) !void { + var buf: [4096]u8 = undefined; + var writer = std.Io.File.stdout().writer(io, &buf); + const out = &writer.interface; + try writeDefaultKeys(out); + try out.flush(); +} + /// Entry point for the interactive TUI. /// `args` contains only command-local tokens (everything after `interactive`). pub fn run( @@ -2286,7 +2336,7 @@ pub fn run( var i: usize = 0; while (i < args.len) : (i += 1) { if (std.mem.eql(u8, args[i], "--default-keys")) { - try keybinds.printDefaults(io); + try printDefaultKeys(io); return; } else if (std.mem.eql(u8, args[i], "--default-theme")) { try theme.printDefaults(io); @@ -2700,7 +2750,7 @@ test "formatStatusHint: empty fragments returns empty string" { } test "formatStatusHint: single fragment has no separator" { - var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + var arena_state: std.heap.ArenaAllocator = .init(testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); @@ -2710,3 +2760,68 @@ test "formatStatusHint: single fragment has no separator" { const out = try formatStatusHint(arena, &fragments); try testing.expectEqualStrings("ctrl+s save", out); } + +test "writeDefaultKeys: includes preamble, global section, and per-tab sections" { + var aw: std.Io.Writer.Allocating = .init(testing.allocator); + defer aw.deinit(); + try writeDefaultKeys(&aw.writer); + const out = aw.written(); + + // Preamble. + try testing.expect(std.mem.indexOf(u8, out, "#!srfv1") != null); + try testing.expect(std.mem.indexOf(u8, out, "Regenerate: zfin interactive --default-keys") != null); + + // Global section header + at least one global binding (un-scoped). + try testing.expect(std.mem.indexOf(u8, out, "# ── Global ──") != null); + try testing.expect(std.mem.indexOf(u8, out, "action::quit,key::q") != null); + + // At least one tab section appears, and bindings inside it carry + // their `scope::,` prefix (the user-edit format). + try testing.expect(std.mem.indexOf(u8, out, "# ── Tab: ") != null); + try testing.expect(std.mem.indexOf(u8, out, "scope::") != null); +} + +test "writeDefaultKeys: every registered tab with default_bindings has a section" { + var aw: std.Io.Writer.Allocating = .init(testing.allocator); + defer aw.deinit(); + try writeDefaultKeys(&aw.writer); + const out = aw.written(); + + inline for (std.meta.fields(@TypeOf(tab_modules))) |field| { + const Module = @field(tab_modules, field.name); + if (Module.tab.default_bindings.len == 0) continue; + + const heading = "# ── Tab: " ++ field.name ++ " ──"; + if (std.mem.indexOf(u8, out, heading) == null) { + std.debug.print("missing tab section: {s}\n", .{heading}); + return error.MissingTabSection; + } + + // And every binding for that tab must show up as a `scope::,action::` line. + inline for (Module.tab.default_bindings) |binding| { + const needle = "scope::" ++ field.name ++ ",action::" ++ @tagName(binding.action); + if (std.mem.indexOf(u8, out, needle) == null) { + std.debug.print("missing binding line: {s}\n", .{needle}); + return error.MissingBindingLine; + } + } + } +} + +test "writeDefaultKeys: tab sections appear in tab_modules declaration order" { + var aw: std.Io.Writer.Allocating = .init(testing.allocator); + defer aw.deinit(); + try writeDefaultKeys(&aw.writer); + const out = aw.written(); + + var prev_pos: usize = 0; + inline for (std.meta.fields(@TypeOf(tab_modules))) |field| { + const Module = @field(tab_modules, field.name); + if (Module.tab.default_bindings.len == 0) continue; + + const heading = "# ── Tab: " ++ field.name ++ " ──"; + const pos = std.mem.indexOf(u8, out, heading) orelse return error.MissingTabSection; + try testing.expect(pos >= prev_pos); + prev_pos = pos; + } +} diff --git a/src/tui/keybinds.zig b/src/tui/keybinds.zig index 52645ea..ee9f179 100644 --- a/src/tui/keybinds.zig +++ b/src/tui/keybinds.zig @@ -307,11 +307,11 @@ fn parseKeyCombo(key_str: []const u8) ?KeyCombo { } /// Print default keybindings in SRF format to stdout. -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; - +/// 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"); @@ -331,13 +331,51 @@ pub fn printDefaults(io: std.Io) !void { 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(); } @@ -691,3 +729,43 @@ test "action_labels: every Action variant has a non-empty label" { 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()); +} diff --git a/src/tui/tab_framework.zig b/src/tui/tab_framework.zig index 7a6405c..e65ac9c 100644 --- a/src/tui/tab_framework.zig +++ b/src/tui/tab_framework.zig @@ -114,11 +114,11 @@ const std = @import("std"); const vaxis = @import("vaxis"); /// Re-exported KeyCombo so tab modules don't need to import -/// keybinds.zig directly for binding declarations. -pub const KeyCombo = struct { - codepoint: u21, - mods: vaxis.Key.Modifiers = .{}, -}; +/// keybinds.zig directly for binding declarations. This is the +/// SAME type as `keybinds.KeyCombo` (re-export, not a copy) — the +/// two names are interchangeable at type-level so values flow +/// freely between framework and keybind code without repacking. +pub const KeyCombo = @import("keybinds.zig").KeyCombo; /// A single (action, key) pair for a tab's `default_bindings`. /// Generic over the tab's Action enum so `default_bindings` can