417 lines
15 KiB
Zig
417 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,
|
|
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 = .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 = .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.items) |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);
|
|
}
|