zfin/src/tui/keybinds.zig

420 lines
15 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,
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);
}