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, scroll_down, scroll_up, scroll_top, scroll_bottom, page_down, page_up, select_next, select_prev, expand_collapse, select_symbol, symbol_input, help, edit, reload_portfolio, collapse_all_calls, collapse_all_puts, options_filter_1, options_filter_2, options_filter_3, options_filter_4, options_filter_5, options_filter_6, options_filter_7, options_filter_8, options_filter_9, chart_timeframe_next, chart_timeframe_prev, sort_col_next, sort_col_prev, sort_reverse, }; pub const KeyCombo = struct { codepoint: u21, mods: vaxis.Key.Modifiers = .{}, }; pub const Binding = struct { action: Action, key: KeyCombo, }; pub const KeyMap = struct { bindings: []const Binding, 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; } }; // ── Defaults ───────────────────────────────────────────────── const 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 = .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_up, .key = .{ .codepoint = vaxis.Key.page_up } }, .{ .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 = .expand_collapse, .key = .{ .codepoint = vaxis.Key.enter } }, .{ .action = .select_symbol, .key = .{ .codepoint = 's' } }, .{ .action = .symbol_input, .key = .{ .codepoint = '/' } }, .{ .action = .help, .key = .{ .codepoint = '?' } }, .{ .action = .edit, .key = .{ .codepoint = 'e' } }, .{ .action = .reload_portfolio, .key = .{ .codepoint = 'R' } }, .{ .action = .collapse_all_calls, .key = .{ .codepoint = 'c' } }, .{ .action = .collapse_all_puts, .key = .{ .codepoint = 'p' } }, .{ .action = .options_filter_1, .key = .{ .codepoint = '1', .mods = .{ .ctrl = true } } }, .{ .action = .options_filter_2, .key = .{ .codepoint = '2', .mods = .{ .ctrl = true } } }, .{ .action = .options_filter_3, .key = .{ .codepoint = '3', .mods = .{ .ctrl = true } } }, .{ .action = .options_filter_4, .key = .{ .codepoint = '4', .mods = .{ .ctrl = true } } }, .{ .action = .options_filter_5, .key = .{ .codepoint = '5', .mods = .{ .ctrl = true } } }, .{ .action = .options_filter_6, .key = .{ .codepoint = '6', .mods = .{ .ctrl = true } } }, .{ .action = .options_filter_7, .key = .{ .codepoint = '7', .mods = .{ .ctrl = true } } }, .{ .action = .options_filter_8, .key = .{ .codepoint = '8', .mods = .{ .ctrl = true } } }, .{ .action = .options_filter_9, .key = .{ .codepoint = '9', .mods = .{ .ctrl = true } } }, .{ .action = .chart_timeframe_next, .key = .{ .codepoint = ']' } }, .{ .action = .chart_timeframe_prev, .key = .{ .codepoint = '[' } }, .{ .action = .sort_col_next, .key = .{ .codepoint = '>' } }, .{ .action = .sort_col_prev, .key = .{ .codepoint = '<' } }, .{ .action = .sort_reverse, .key = .{ .codepoint = 'o' } }, }; pub fn defaults() KeyMap { return .{ .bindings = &default_bindings }; } // ── 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. pub fn printDefaults() !void { var buf: [4096]u8 = undefined; var writer = std.fs.File.stdout().writer(&buf); const out = &writer.interface; 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\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"); for (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 }); } try out.flush(); } // ── SRF loading ────────────────────────────────────────────── fn parseAction(name: []const u8) ?Action { inline for (std.meta.fields(Action)) |f| { if (std.mem.eql(u8, name, f.name)) return @enumFromInt(f.value); } return 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(). pub fn loadFromFile(allocator: std.mem.Allocator, path: []const u8) ?KeyMap { const data = std.fs.cwd().readFileAlloc(allocator, path, 64 * 1024) catch return null; defer allocator.free(data); return loadFromData(allocator, data); } pub fn loadFromData(allocator: std.mem.Allocator, data: []const u8) ?KeyMap { const arena = allocator.create(std.heap.ArenaAllocator) catch return null; arena.* = std.heap.ArenaAllocator.init(allocator); errdefer { arena.deinit(); allocator.destroy(arena); } const aa = arena.allocator(); var reader = std.Io.Reader.fixed(data); const parsed = srf.parse(&reader, aa, .{}) catch return null; // Don't defer parsed.deinit() -- arena owns everything var bindings = std.ArrayList(Binding).empty; for (parsed.records) |record| { var action: ?Action = null; var key: ?KeyCombo = null; for (record.fields) |field| { if (std.mem.eql(u8, field.key, "action")) { if (field.value) |v| { switch (v) { .string => |s| action = parseAction(s), else => {}, } } } else if (std.mem.eql(u8, field.key, "key")) { if (field.value) |v| { switch (v) { .string => |s| key = parseKeyCombo(s), else => {}, } } } } if (action != null and key != null) { bindings.append(aa, .{ .action = action.?, .key = key.? }) catch return null; } } return .{ .bindings = bindings.toOwnedSlice(aa) catch return null, .arena = arena, }; } // ── 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); } 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); }