//! Keybinding helpers: declare named bindings with help text, then match //! incoming `Msg.Key` events against them. //! //! const k = leaves.key; //! const up = k.Binding.new(&.{ k.up, k.k }, "move up", 2); //! // up.help.key == "↑/k" //! if (up.matches(msg_key)) { ... } const std = @import("std"); const tea = @import("blacktea"); const Key = tea.Msg.Key; const Help = struct { key: []const u8 = "", desc: []const u8 = "", }; pub const Binding = struct { keys: []const Key = &.{}, help: Help = .{}, disabled: bool = false, pub fn new(comptime keys: []const Key, comptime desc: []const u8, comptime options: Options) Binding { const max_shown_keys = if (options.max_shown_keys) |shown| shown else keys.len; comptime var string: []const u8 = ""; comptime { const limit = @min(max_shown_keys, keys.len); for (keys[0..limit], 0..) |key, i| { if (i > 0) string = string ++ "/"; string = string ++ keyLabel(key); } } return .{ .keys = keys, .help = .{ .key = string, .desc = desc }, .disabled = options.disabled, }; } pub fn enabled(self: Binding) bool { return !self.disabled and self.keys.len > 0; } pub fn matches(self: Binding, k: Key) bool { if (!self.enabled()) return false; for (self.keys) |bound| if (keyEql(bound, k)) return true; return false; } pub const up: Binding = .new(&.{ .up, .k }, "up", .{}); pub const down: Binding = .new(&.{ .down, .j }, "down", .{}); pub const left: Binding = .new(&.{ .left, .k }, "left", .{}); pub const right: Binding = .new(&.{ .right, .l }, "right", .{}); pub const quit: Binding = .new(&.{.q}, "quit", .{}); pub const force_quit: Binding = .new(&.{.ctrl(.c)}, "", .{}); const Options = struct { max_shown_keys: ?usize = null, disabled: bool = false, pub fn show(keys: usize) Options { return .{ .max_shown_keys = keys }; } }; }; /// Returns true if `k` matches any of the supplied bindings. pub fn matchesAny(k: Key, bindings: []const Binding) bool { for (bindings) |b| if (b.matches(k)) return true; return false; } fn keyEql(a: Key, b: Key) bool { if (a.kind != b.kind) return false; if (a.kind == .rune and a.codepoint != b.codepoint) return false; return @as(u8, @bitCast(a.mods)) == @as(u8, @bitCast(b.mods)); } fn keyLabel(comptime key: Key) []const u8 { comptime var s: []const u8 = ""; comptime { if (key.mods.ctrl) s = s ++ "ctrl+"; if (key.mods.alt) s = s ++ "alt+"; if (key.mods.shift) s = s ++ "shift+"; if (key.mods.meta) s = s ++ "meta+"; if (key.mods.hyper) s = s ++ "hyper+"; if (key.mods.super) s = s ++ "super+"; s = s ++ switch (key.kind) { .rune => runeBytes(key.codepoint), .enter => "enter", .space => "space", .tab => "tab", .backspace => "backspace", .escape => "esc", .up => "↑", .down => "↓", .left => "←", .right => "→", .home => "home", .end => "end", .page_up => "pgup", .page_down => "pgdn", .begin => "begin", .insert => "ins", .delete => "del", .f1 => "f1", .f2 => "f2", .f3 => "f3", .f4 => "f4", .f5 => "f5", .f6 => "f6", .f7 => "f7", .f8 => "f8", .f9 => "f9", .f10 => "f10", .f11 => "f11", .f12 => "f12", .f13 => "f13", .f14 => "f14", .f15 => "f15", .f16 => "f16", .f17 => "f17", .f18 => "f18", .f19 => "f19", .f20 => "f20", }; } return s; } /// UTF-8 encoding of a comptime codepoint as a static string. /// Rebuilding an array literal from the encoded bytes breaks /// provenance on the local `buf`, so the result is safe as a field /// default (unlike a direct `buf[0..n]` slice). fn runeBytes(comptime codepoint: u21) []const u8 { comptime { var buf: [4]u8 = undefined; const n = std.unicode.utf8Encode(codepoint, &buf) catch @compileError("invalid codepoint"); if (n == 1) return &[_]u8{buf[0]}; if (n == 2) return &[_]u8{ buf[0], buf[1] }; if (n == 3) return &[_]u8{ buf[0], buf[1], buf[2] }; return &[_]u8{ buf[0], buf[1], buf[2], buf[3] }; } } test "binding matches rune" { const b: Binding = .{ .keys = &.{ .k, .up } }; try std.testing.expect(b.matches(.k)); try std.testing.expect(b.matches(.up)); try std.testing.expect(!b.matches(.j)); } test "disabled binding never matches" { const b: Binding = .{ .keys = &.{.k}, .disabled = true }; try std.testing.expect(!b.matches(.w)); try std.testing.expect(!b.enabled()); } test "mods are required to match" { const b: Binding = .{ .keys = &.{.ctrl(.c)} }; try std.testing.expect(b.matches(.ctrl(.c))); try std.testing.expect(!b.matches(.c)); } test "matchesAny" { const up: Binding = .{ .keys = &.{.up} }; const down: Binding = .{ .keys = &.{.down} }; try std.testing.expect(matchesAny(.up, &.{ up, down })); try std.testing.expect(!matchesAny(.left, &.{ up, down })); } test "new builds help.key at comptime" { const b = comptime Binding.new(&.{ .k, .up, .ctrl(.p) }, "up", .show(2)); try std.testing.expectEqualStrings("k/↑", b.help.key); try std.testing.expectEqualStrings("up", b.help.desc); } test "new as struct field default" { const KeyMap = struct { goto_top: Binding = .new(&.{.g}, "first", .{}), down: Binding = .new(&.{ .j, .down, .ctrl(.n) }, "down", .show(2)), }; const m: KeyMap = .{}; try std.testing.expectEqualStrings("g", m.goto_top.help.key); try std.testing.expectEqualStrings("j/↓", m.down.help.key); }