get tab-scoped keys into the default keys output

This commit is contained in:
Emil Lerch 2026-05-15 15:28:50 -07:00
parent 8dc01e81ae
commit 0c3ddd1ffc
Signed by: lobo
GPG key ID: A7B62D657EF764F8
3 changed files with 213 additions and 20 deletions

View file

@ -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::<tab>,` 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::<tab>,action::<name>` 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;
}
}

View file

@ -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::<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();
}
@ -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());
}

View file

@ -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