Something went wrong. Try again.
A history browser for git, a replacement for gitk, drawn with Vulkan on Wayland.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! Key bindings: `gitk`'s, and the configuration's over them.//!//! A binding is written `ctrl+g`: modifiers, then a key. The key is a//! character, matched against what the key types with no modifiers — so//! `ctrl+g` is the G key on any layout that has one — or against what it//! types, for one like `/` that takes shift on some layouts; or it is the//! name of a key that types nothing (`page_up`, `f5`), matched by keysym.
const std = @import("std");const aerie = @import("aerie");const ev = aerie.platform.event;
const Config = @import("../config/Config.zig");
pub const Action = Config.Action;
pub const Trigger = struct { shift: bool = false, ctrl: bool = false, alt: bool = false, super: bool = false, key: union(enum) { char: u21, keysym: u32, },
pub fn parse(s: []const u8) error{InvalidBinding}!Trigger { var t: Trigger = .{ .key = .{ .char = 0 } }; var it = std.mem.splitScalar(u8, s, '+'); var last: ?[]const u8 = null; while (it.next()) |part| { if (last) |mod| { if (std.ascii.eqlIgnoreCase(mod, "shift")) { t.shift = true; } else if (std.ascii.eqlIgnoreCase(mod, "ctrl") or std.ascii.eqlIgnoreCase(mod, "control")) { t.ctrl = true; } else if (std.ascii.eqlIgnoreCase(mod, "alt")) { t.alt = true; } else if (std.ascii.eqlIgnoreCase(mod, "super")) { t.super = true; } else return error.InvalidBinding; } last = part; } const name = last orelse return error.InvalidBinding; if (name.len == 0) { // Nothing after the last plus; the plus key is written "plus". return error.InvalidBinding; } else if ((std.unicode.utf8CountCodepoints(name) catch 0) == 1) { const cp = std.unicode.utf8Decode(name) catch return error.InvalidBinding; t.key = .{ .char = if (cp < 0x80) std.ascii.toLower(@intCast(cp)) else cp }; } else if (std.ascii.eqlIgnoreCase(name, "plus")) { t.key = .{ .char = '+' }; } else if (std.ascii.eqlIgnoreCase(name, "space")) { t.key = .{ .char = ' ' }; } else if (named(name)) |sym| { t.key = .{ .keysym = sym }; } else return error.InvalidBinding; return t; }
pub fn matches(t: Trigger, k: ev.Key) bool { if (t.ctrl != k.mods.ctrl or t.alt != k.mods.alt or t.super != k.mods.super) return false; return switch (t.key) { .char => |c| (t.shift == k.mods.shift and c == k.unshifted) or // What the key typed, with shift if the layout wanted it // for this character: `?`, or `/` on a German layout. (k.text.len > 0 and c == (std.unicode.utf8Decode(k.text) catch 0) and (t.shift == k.mods.shift or (!t.shift and k.consumed.shift))), .keysym => |s| t.shift == k.mods.shift and s == canonical(k.keysym), }; }};
/// The keysyms of keys that type nothing, by the names a binding uses.const names = [_]struct { []const u8, u32 }{ .{ "arrow_up", 0xff52 }, .{ "arrow_down", 0xff54 }, .{ "arrow_left", 0xff51 }, .{ "arrow_right", 0xff53 }, .{ "up", 0xff52 }, .{ "down", 0xff54 }, .{ "left", 0xff51 }, .{ "right", 0xff53 }, .{ "page_up", 0xff55 }, .{ "page_down", 0xff56 }, .{ "home", 0xff50 }, .{ "end", 0xff57 }, .{ "insert", 0xff63 }, .{ "delete", 0xffff }, .{ "backspace", 0xff08 }, .{ "tab", 0xff09 }, .{ "enter", 0xff0d }, .{ "return", 0xff0d }, .{ "escape", 0xff1b },};
fn named(name: []const u8) ?u32 { for (names) |n| if (std.ascii.eqlIgnoreCase(name, n[0])) return n[1]; // f1 to f24. if (name.len >= 2 and (name[0] == 'f' or name[0] == 'F')) { const n = std.fmt.parseInt(u8, name[1..], 10) catch return null; if (n >= 1 and n <= 24) return 0xffbe + @as(u32, n) - 1; } return null;}
/// The keysym a binding names for `sym`: the keypad's keys, with Num Lock/// off, are the keys they share a name with, and shift+tab is tab.fn canonical(sym: u32) u32 { return switch (sym) { 0xff8d => 0xff0d, // KP_Enter 0xff95 => 0xff50, // KP_Home 0xff96 => 0xff51, // KP_Left 0xff97 => 0xff52, // KP_Up 0xff98 => 0xff53, // KP_Right 0xff99 => 0xff54, // KP_Down 0xff9a => 0xff55, // KP_Page_Up 0xff9b => 0xff56, // KP_Page_Down 0xff9c => 0xff57, // KP_End 0xff9e => 0xff63, // KP_Insert 0xff9f => 0xffff, // KP_Delete 0xfe20 => 0xff09, // ISO_Left_Tab else => sym, };}
pub const Binding = struct { trigger: Trigger, action: Action };
/// `gitk`'s keys.pub const defaults = [_]struct { []const u8, Action }{ .{ "arrow_up", .select_previous }, .{ "k", .select_previous }, .{ "arrow_down", .select_next }, .{ "j", .select_next }, .{ "page_up", .select_page_up }, .{ "page_down", .select_page_down }, .{ "home", .select_first }, .{ "end", .select_last }, .{ "space", .diff_page_down }, .{ "b", .diff_page_up }, .{ "backspace", .diff_page_up }, .{ "/", .find }, .{ "ctrl+f", .find }, .{ "ctrl+g", .find_next }, .{ "ctrl+shift+g", .find_previous }, .{ "f5", .reload }, .{ "ctrl+c", .copy }, .{ "ctrl+q", .quit },};
/// The defaults with `configured` applied over them. A binding that is not/// one is named in `bad`, if there is one, and left out.pub fn load(gpa: std.mem.Allocator, configured: []const Config.Keybind, bad: ?*?[]const u8) std.mem.Allocator.Error![]Binding { var list: std.ArrayList(Binding) = .empty; errdefer list.deinit(gpa); for (defaults) |d| try list.append(gpa, .{ .trigger = Trigger.parse(d[0]) catch unreachable, .action = d[1] }); for (configured) |c| { const trigger = Trigger.parse(c.keys) catch { if (bad) |b| b.* = c.keys; continue; }; var i: usize = 0; while (i < list.items.len) { if (std.meta.eql(list.items[i].trigger, trigger)) _ = list.orderedRemove(i) else i += 1; } if (c.action != .unbind) try list.append(gpa, .{ .trigger = trigger, .action = c.action }); } return list.toOwnedSlice(gpa);}
pub fn find(bindings: []const Binding, k: ev.Key) ?Action { if (k.action == .release) return null; for (bindings) |b| if (b.trigger.matches(k)) return b.action; return null;}
const testing = std.testing;
fn key(keysym: u32, unshifted: u21, text: []const u8, mods: ev.Mods, consumed: ev.Mods) ev.Key { return .{ .action = .press, .code = 0, .keysym = keysym, .text = text, .mods = mods, .consumed = consumed, .unshifted = unshifted, .composing = false };}
test "parsing and matching" { const t = try Trigger.parse("ctrl+shift+g"); try testing.expect(t.matches(key('G', 'g', "", .{ .ctrl = true, .shift = true }, .{}))); try testing.expect(!t.matches(key('g', 'g', "", .{ .ctrl = true }, .{}))); const pu = try Trigger.parse("page_up"); try testing.expect(pu.matches(key(0xff55, 0, "", .{}, .{}))); // The keypad's, with Num Lock off. try testing.expect(pu.matches(key(0xff9a, 0, "", .{}, .{}))); try testing.expect(!pu.matches(key(0xff55, 0, "", .{ .shift = true }, .{}))); try testing.expectEqual(@as(u32, 0xffc2), (try Trigger.parse("f5")).key.keysym); try testing.expectError(error.InvalidBinding, Trigger.parse("hyper+x")); try testing.expectError(error.InvalidBinding, Trigger.parse("ctrl+")); try testing.expectError(error.InvalidBinding, Trigger.parse("f25"));}
test "a character that takes shift on this layout" { // `?` is shift+/ on a US layout. const q = try Trigger.parse("?"); try testing.expect(q.matches(key('?', '/', "?", .{ .shift = true }, .{ .shift = true }))); // `/` is shift+7 on a German one. const slash = try Trigger.parse("/"); try testing.expect(slash.matches(key('/', '7', "/", .{ .shift = true }, .{ .shift = true }))); try testing.expect(!slash.matches(key('/', '/', "/", .{ .ctrl = true }, .{})));}
test "configuration replaces and unbinds" { var bad: ?[]const u8 = null; const list = try load(testing.allocator, &.{ .{ .keys = "ctrl+q", .action = .unbind }, .{ .keys = "ctrl+w", .action = .quit }, .{ .keys = "j", .action = .select_previous }, .{ .keys = "nonsense+x", .action = .quit }, }, &bad); defer testing.allocator.free(list); try testing.expect(find(list, key('q', 'q', "", .{ .ctrl = true }, .{})) == null); try testing.expectEqual(Action.quit, find(list, key('w', 'w', "", .{ .ctrl = true }, .{})).?); try testing.expectEqual(Action.select_previous, find(list, key('j', 'j', "j", .{}, .{})).?); try testing.expectEqualStrings("nonsense+x", bad.?);}