Add better and configurable shortcuts.

This commit is contained in:
Greyson Parrelli
2026-08-15 00:00:06 -04:00
parent 043b994a15
commit a742b51d3b
6 changed files with 987 additions and 136 deletions
+198 -1
View File
@@ -16,7 +16,10 @@
//! The overrides deliberately stay out of that arena. They are a fixed-size
//! array of optionals, which needs no allocation at all — and, more to the
//! point, `setStartup` replaces the arena wholesale, so anything colour-shaped
//! living in it would be freed by an unrelated edit to the startup list.
//! living in it would be freed by an unrelated edit to the startup list. The
//! **shortcut overrides** are the same shape for the same reason: a chord is a
//! key and four flags once it has been parsed, so nothing about it needs to
//! outlive the parse as text.
//!
//! Because saving rewrites the whole file, there is exactly one `Settings` for
//! the process, reached through `get`. Two holders each convinced they knew
@@ -32,6 +35,7 @@ const std = @import("std");
const glib = @import("glib");
const palette = @import("palette.zig");
const shortcuts = @import("shortcuts.zig");
const Settings = @This();
@@ -130,6 +134,11 @@ colors: Colors = .{},
/// which is what the app did before this setting existed.
startup: []const StartupTab = &.{},
/// The shortcuts someone has rebound. Empty means every action answers to the
/// chord this build ships for it — see `shortcuts.zig` for the table and for
/// what an entry here replaces.
keys: shortcuts.Overrides = shortcuts.no_overrides,
// -------------------------------------------------------------------------
// The process-wide instance
@@ -236,6 +245,8 @@ fn parse(self: *Settings, text: []const u8) ParseError!void {
if (root.get("colors")) |value| self.parseColors(value);
if (root.get("shortcuts")) |value| self.parseShortcuts(value);
if (root.get("startup")) |value| try self.parseStartup(value);
}
@@ -293,6 +304,75 @@ fn parseColors(self: *Settings, raw: std.json.Value) void {
}
}
/// Read the rebound shortcuts: `{"focus_pane_left": "ctrl+shift+h", …}`.
///
/// A value is either one chord or a list of them, because most actions want one
/// and writing `["ctrl+shift+h"]` for the common case would be noise. An empty
/// list — or `null` — is how a shortcut is turned off, which is worth having:
/// the chords here are ones a program running in the terminal may want back.
///
/// Nothing here can fail the file, on the same grounds as the colours above. An
/// action this build doesn't have, or a chord that doesn't parse, costs that one
/// binding and is logged; what it falls back to is the shipped default, which is
/// exactly what an absent entry already means.
fn parseShortcuts(self: *Settings, raw: std.json.Value) void {
const obj = switch (raw) {
.object => |o| o,
else => {
std.log.warn("settings: \"shortcuts\" is not an object; ignoring it", .{});
return;
},
};
var it = obj.iterator();
while (it.next()) |kv| {
const action = std.meta.stringToEnum(shortcuts.Action, kv.key_ptr.*) orelse {
std.log.warn("settings: no shortcut called \"{s}\"; ignoring it", .{kv.key_ptr.*});
continue;
};
var set: shortcuts.ChordSet = .{};
switch (kv.value_ptr.*) {
.string => |text| addChord(&set, text, action),
.array => |items| for (items.items) |item| {
switch (item) {
.string => |text| addChord(&set, text, action),
else => std.log.warn(
"settings: shortcuts.{s} holds something that is not a chord",
.{@tagName(action)},
),
}
},
// Both spellings of "leave this key alone".
.null => {},
else => {
std.log.warn(
"settings: shortcuts.{s} is not a chord or a list of them",
.{@tagName(action)},
);
continue;
},
}
self.keys.set(action, set);
}
}
fn addChord(set: *shortcuts.ChordSet, text: []const u8, action: shortcuts.Action) void {
const chord = shortcuts.parse(text) orelse {
std.log.warn(
"settings: \"{s}\" is not a chord this can bind; shortcuts need Ctrl, Alt or Super",
.{text},
);
return;
};
if (!set.add(chord)) {
std.log.warn("settings: {s} already has {d} chords; ignoring \"{s}\"", .{
@tagName(action), shortcuts.max_chords, text,
});
}
}
/// Read the startup list.
///
/// Entries that make no sense are skipped rather than failing the file. The
@@ -479,6 +559,33 @@ fn serialize(self: *Settings) SaveError![]u8 {
}
try json.endObject();
// Only the shortcuts that have been rebound, for the same reasons as the
// colours — and with the same consequence, which is the one that matters
// here: saving rewrites the whole file, so a hand-edited shortcut has to
// come back out of this function or changing the theme would delete it.
// Chords are written from the parsed form rather than the text they came
// in as, so `Shift + CTRL + H` is read once and stored as `ctrl+shift+h`.
try json.objectField("shortcuts");
try json.beginObject();
for (std.enums.values(shortcuts.Action)) |action| {
const set = self.keys.get(action) orelse continue;
try json.objectField(@tagName(action));
var buf: [shortcuts.text_len]u8 = undefined;
if (set.len == 1) {
try json.write(set.slice()[0].text(&buf));
continue;
}
// A list for none and for several. None is a shortcut someone has
// switched off, and it has to survive the round trip as emphatically as
// a bound one does: dropping it would hand the key back.
try json.beginArray();
for (set.slice()) |c| try json.write(c.text(&buf));
try json.endArray();
}
try json.endObject();
// Written even when empty, so that the key is discoverable in a file
// someone has opened to see what they can put in it.
try json.objectField("startup");
@@ -624,6 +731,96 @@ test "only the changed colours are written, and they come back" {
}
}
test "a shortcut can be given one chord or several" {
var settings = forTesting();
defer settings.arena.deinit();
try settings.parse(
\\{"shortcuts": {
\\ "focus_pane_left": "ctrl+alt+h",
\\ "toggle_zoom": ["ctrl+shift+z", "super+f"],
\\ "new_tab": []
\\}}
);
const left = settings.keys.get(.focus_pane_left).?;
try std.testing.expectEqual(@as(usize, 1), left.len);
try std.testing.expectEqual(shortcuts.parse("ctrl+alt+h").?, left.slice()[0]);
const zoom = settings.keys.get(.toggle_zoom).?;
try std.testing.expectEqual(@as(usize, 2), zoom.len);
try std.testing.expectEqual(shortcuts.parse("super+f").?, zoom.slice()[1]);
// Present but empty is a shortcut switched off, which is not the same as
// absent — absent is the default, and this has to outrank it.
try std.testing.expectEqual(@as(usize, 0), settings.keys.get(.new_tab).?.len);
try std.testing.expectEqual(@as(?shortcuts.ChordSet, null), settings.keys.get(.close_pane));
}
test "a chord we can't read costs that chord and nothing else" {
var settings = forTesting();
defer settings.arena.deinit();
try settings.parse(
\\{"theme": "dark", "shortcuts": {
\\ "not_an_action": "ctrl+shift+q",
\\ "next_tab": ["ctrl+nonesuch", "shift+j", "ctrl+alt+j"],
\\ "prev_tab": 12
\\}}
);
// Nothing bindable was named for prev_tab, so it keeps its defaults.
try std.testing.expectEqual(@as(?shortcuts.ChordSet, null), settings.keys.get(.prev_tab));
// The unreadable chord and the one with no claiming modifier are dropped;
// the third is kept, as is the theme above them.
const next = settings.keys.get(.next_tab).?;
try std.testing.expectEqual(@as(usize, 1), next.len);
try std.testing.expectEqual(shortcuts.parse("ctrl+alt+j").?, next.slice()[0]);
try std.testing.expectEqual(Theme.dark, settings.theme);
}
test "rebound shortcuts survive a save that was about something else" {
var settings = forTesting();
defer settings.arena.deinit();
// The file as someone hand-edited it, then the theme change that rewrites
// the whole thing. What is being checked is that the rewrite carries the
// shortcuts back out with it.
try settings.parse(
\\{"shortcuts": {"focus_pane_down": "ctrl+alt+j", "copy": [], "toggle_zoom": ["ctrl+shift+z", "super+f"]}}
);
settings.theme = .light;
const text = try settings.serialize();
defer std.testing.allocator.free(text);
try std.testing.expect(std.mem.indexOf(u8, text, "\"focus_pane_down\": \"ctrl+alt+j\"") != null);
try std.testing.expect(std.mem.indexOf(u8, text, "\"copy\": []") != null);
// And nothing that was left at its default is written, so the file stays as
// short as the changes it records.
try std.testing.expect(std.mem.indexOf(u8, text, "\"new_tab\"") == null);
var read_back = forTesting();
defer read_back.arena.deinit();
try read_back.parse(text);
for (std.enums.values(shortcuts.Action)) |action| {
try std.testing.expectEqual(settings.keys.get(action), read_back.keys.get(action));
}
}
test "shortcuts is written even when nothing has been rebound" {
var settings = forTesting();
defer settings.arena.deinit();
const text = try settings.serialize();
defer std.testing.allocator.free(text);
try std.testing.expect(std.mem.indexOf(u8, text, "\"shortcuts\": {}") != null);
}
test "a scheme with nothing changed is left out of the file entirely" {
var settings = forTesting();
defer settings.arena.deinit();
+27
View File
@@ -611,6 +611,33 @@ pub fn moveFocused(self: *View, side: Side) void {
self.moveTo(pane, .{ .pane = target, .side = side }, 0.5);
}
/// Move focus one step in a direction, to whichever pane is sitting there.
///
/// The counterpart of `moveFocused`: same neighbour, but the pane stays where
/// it is and you go to it instead. Both are answered geometrically rather than
/// by walking the split tree, so "the pane to the left" means the one that
/// looks like it — a tree walk would have to pick between siblings and cousins,
/// and on screen there is no such distinction.
///
/// Reports whether focus actually moved. Nothing that way is not a failure
/// worth telling anyone about, but it is worth the caller knowing, since the
/// edges of a view are where a shortcut might reasonably do something else.
pub fn focusNeighbor(self: *View, side: Side) bool {
const pane = self.focusedPane() orelse return false;
if (self.panes.items.len < 2) return false;
// While zoomed there is only one pane on screen and the others are
// unparented, so there is nothing to probe for and nothing to move to. The
// arrangement is exactly what zoom is hiding; leaving it to hunt for a
// neighbour would undo what the user asked for.
if (self.zoomed != null) return false;
const target = self.neighbor(pane, side) orelse return false;
self.setFocused(target);
target.grabFocus();
return true;
}
/// The pane visually adjacent to `pane` on the given side, found by probing
/// just past the pane's edge and asking which pane covers that point.
fn neighbor(self: *View, pane: *Pane, side: Side) ?*Pane {
+123 -115
View File
@@ -26,6 +26,8 @@ const Terminal = @import("Terminal.zig");
const View = @import("View.zig");
const appearance = @import("appearance.zig");
const emoji = @import("emoji.zig");
const key = @import("key.zig");
const shortcuts = @import("shortcuts.zig");
const Window = @This();
@@ -298,16 +300,16 @@ pub fn create(alloc: std.mem.Allocator, app: *adw.Application) !*Window {
// Window-level shortcuts run in the capture phase so they are handled
// before the focused terminal turns the key into a VT sequence.
const shortcuts = gtk.EventControllerKey.new();
shortcuts.as(gtk.EventController).setPropagationPhase(.capture);
const keys = gtk.EventControllerKey.new();
keys.as(gtk.EventController).setPropagationPhase(.capture);
_ = gtk.EventControllerKey.signals.key_pressed.connect(
shortcuts,
keys,
*Window,
&onShortcut,
self,
.{},
);
window.as(gtk.Widget).addController(shortcuts.as(gtk.EventController));
window.as(gtk.Widget).addController(keys.as(gtk.EventController));
// Free our own state once GTK is done with the window. Doing this on
// `destroy` rather than `close-request` means no further events can
@@ -1603,6 +1605,17 @@ fn cycle(self: *Window, delta: isize) void {
self.selectIndex(@intCast(next));
}
/// Turn a key press into an action, and run it.
///
/// This used to be a switch over keyvals, and is now a table lookup, because
/// the chords are configurable — see `shortcuts.zig`. What is left here is the
/// translation into a chord and the doing of each action; which chord means
/// which action is no longer this file's business.
///
/// The modifier match is exact, which the switch it replaced was not: it tested
/// `ctrl and shift` and so also fired on Ctrl+Alt+Shift+T. Requiring the whole
/// set to agree is what makes two chords over the same key — Alt+J and
/// Alt+Shift+J — reliably different things.
fn onShortcut(
_: *gtk.EventControllerKey,
keyval: c_uint,
@@ -1610,121 +1623,116 @@ fn onShortcut(
state: gdk.ModifierType,
self: *Window,
) callconv(.c) c_int {
const ctrl = state.control_mask;
const shift = state.shift_mask;
const alt = state.alt_mask;
const mods: shortcuts.Mods = .{
.ctrl = state.control_mask,
.alt = state.alt_mask,
.shift = state.shift_mask,
.super = state.super_mask,
};
if (ctrl and shift) {
switch (keyval) {
gdk.KEY_T, gdk.KEY_t => {
self.newTab() catch |err| {
std.log.err("failed to open tab: {s}", .{@errorName(err)});
};
return 1;
},
gdk.KEY_W, gdk.KEY_w => {
// Closes the focused pane. The view raises on_empty when its
// last pane goes, which is what closes the tab.
if (self.activeTab()) |tab| {
if (tab.view.focusedPane()) |pane| tab.view.closePane(pane);
}
return 1;
},
gdk.KEY_E, gdk.KEY_e => {
self.addPane(.terminal);
return 1;
},
gdk.KEY_B, gdk.KEY_b => {
self.addPane(.web);
return 1;
},
gdk.KEY_R, gdk.KEY_r => {
if (self.activeTab()) |tab| self.beginRename(tab);
return 1;
},
gdk.KEY_Z, gdk.KEY_z => {
if (self.activeTab()) |tab| tab.view.toggleZoomFocused();
return 1;
},
gdk.KEY_V, gdk.KEY_v => {
// Only a terminal needs us to encode a paste for it. A web
// pane has its own clipboard handling, so the key is left
// alone rather than swallowed here.
const terminal = self.focusedTerminal() orelse return 0;
terminal.pasteFrom(.standard);
return 1;
},
gdk.KEY_C, gdk.KEY_c => {
// With nothing selected the key is declined rather than
// swallowed, so a web pane's own copy still works and a
// terminal still receives it.
const terminal = self.focusedTerminal() orelse return 0;
return if (terminal.copySelection(.standard)) 1 else 0;
},
else => {},
}
}
// Every shortcut needs one of these, so ordinary typing — which arrives
// here first, on every key — is declined before anything is looked up.
if (!mods.claiming()) return 0;
// Ctrl+Shift+arrows rearrange the focused terminal within its view. This
// is the keyboard route to the same rearranging that dragging a pane's
// header does.
if (ctrl and shift) {
const side: ?View.Side = switch (keyval) {
gdk.KEY_Left => .left,
gdk.KEY_Right => .right,
gdk.KEY_Up => .top,
gdk.KEY_Down => .bottom,
else => null,
};
if (side) |s| {
if (self.activeTab()) |tab| tab.view.moveFocused(s);
return 1;
}
}
// Shift turns the letter keys into their capitals, and a chord is written
// as the key you press rather than the character it produces.
const key_val = key.keyFromKeyval(gdk.keyvalToLower(keyval)) orelse return 0;
// Chords without Shift, which have to be the ones a terminal doesn't want
// for itself. Ctrl+PageUp/PageDown cycles tabs, matching most tabbed
// terminals; Ctrl+comma opens settings, which is the convention nearly
// everywhere; Ctrl+F is claimed only over a web pane.
if (ctrl and !shift) {
switch (keyval) {
gdk.KEY_F, gdk.KEY_f => {
// Find-in-page, on the chord every browser uses. In a
// terminal Ctrl+F is an ordinary control character that the
// program running there is waiting for, so this only claims
// the key when a web pane has focus.
const browser = self.focusedBrowser() orelse return 0;
browser.openFind();
return 1;
},
gdk.KEY_comma => {
self.openSettings();
return 1;
},
gdk.KEY_Page_Up => {
self.cycle(-1);
return 1;
},
gdk.KEY_Page_Down => {
self.cycle(1);
return 1;
},
else => {},
}
}
const action = shortcuts.actionFor(
.{ .mods = mods, .key = key_val },
&Settings.get().keys,
) orelse return 0;
// Alt+1..9 jumps straight to a tab; Alt+9 is "last tab" by convention.
if (alt and !ctrl) {
if (keyval >= gdk.KEY_1 and keyval <= gdk.KEY_9) {
const n = keyval - gdk.KEY_1;
if (n == 8) {
self.selectIndex(self.tabs.items.len -| 1);
} else {
self.selectIndex(@intCast(n));
}
return 1;
}
return if (self.perform(action)) 1 else 0;
}
/// Run one action. Returns whether the key was used, which is not the same as
/// whether anything happened: an action that has nothing to act on here — copy
/// with no selection, find outside a web pane — declines the key so that
/// whatever is focused gets it instead, while one that simply had nowhere to go
/// still swallows it rather than sending a stray control code to a shell.
fn perform(self: *Window, action: shortcuts.Action) bool {
switch (action) {
.new_tab => {
self.newTab() catch |err| {
std.log.err("failed to open tab: {s}", .{@errorName(err)});
};
},
// Closes the focused pane. The view raises on_empty when its last pane
// goes, which is what closes the tab.
.close_pane => if (self.activeTab()) |tab| {
if (tab.view.focusedPane()) |pane| tab.view.closePane(pane);
},
.new_terminal => self.addPane(.terminal),
.new_web => self.addPane(.web),
.rename_tab => if (self.activeTab()) |tab| self.beginRename(tab),
.toggle_zoom => if (self.activeTab()) |tab| tab.view.toggleZoomFocused(),
.open_settings => self.openSettings(),
// Only a terminal needs us to encode a paste for it. A web pane has its
// own clipboard handling, so the key is left alone rather than
// swallowed here.
.paste => {
const terminal = self.focusedTerminal() orelse return false;
terminal.pasteFrom(.standard);
},
// With nothing selected the key is declined rather than swallowed, so a
// web pane's own copy still works and a terminal still receives it.
.copy => {
const terminal = self.focusedTerminal() orelse return false;
return terminal.copySelection(.standard);
},
// Find-in-page, on the chord every browser uses. In a terminal Ctrl+F
// is an ordinary control character that the program running there is
// waiting for, so this only claims the key over a web pane.
.find => {
const browser = self.focusedBrowser() orelse return false;
browser.openFind();
},
.prev_tab => self.cycle(-1),
.next_tab => self.cycle(1),
// Moving focus between panes. A view edge with nothing beyond it stops
// the move, but still takes the key: the chord was bound for navigating,
// and sending it on to the shell at the edge of a split would be a
// control code nobody asked for.
.focus_pane_left => _ = self.focusNeighbor(.left),
.focus_pane_right => _ = self.focusNeighbor(.right),
.focus_pane_up => _ = self.focusNeighbor(.top),
.focus_pane_down => _ = self.focusNeighbor(.bottom),
// Moving the pane itself, which is the keyboard route to the same
// rearranging that dragging a pane's header does.
.move_pane_left => self.movePane(.left),
.move_pane_right => self.movePane(.right),
.move_pane_up => self.movePane(.top),
.move_pane_down => self.movePane(.bottom),
.select_tab_1 => self.selectIndex(0),
.select_tab_2 => self.selectIndex(1),
.select_tab_3 => self.selectIndex(2),
.select_tab_4 => self.selectIndex(3),
.select_tab_5 => self.selectIndex(4),
.select_tab_6 => self.selectIndex(5),
.select_tab_7 => self.selectIndex(6),
.select_tab_8 => self.selectIndex(7),
.select_last_tab => self.selectIndex(self.tabs.items.len -| 1),
}
return true;
}
fn focusNeighbor(self: *Window, side: View.Side) bool {
const tab = self.activeTab() orelse return false;
return tab.view.focusNeighbor(side);
}
return 0;
fn movePane(self: *Window, side: View.Side) void {
const tab = self.activeTab() orelse return;
tab.view.moveFocused(side);
}
+541
View File
@@ -0,0 +1,541 @@
//! Which chord runs which action.
//!
//! The window used to answer this with a switch over GDK keyvals, which was
//! fine while the answer was fixed. It isn't any more: the chords here are the
//! ones that fight hardest with what a terminal wants for itself, and whose
//! chord is which is a matter of habit — a vim user reaches for `h/j/k/l` to
//! move between panes and an emacs user does not, and neither is wrong.
//!
//! So a shortcut is a **table entry**, not a branch. `defaults` is the table
//! this build ships with, and `settings.json` may replace the entries for any
//! action it names. Lookup goes the other way round from the way the table
//! reads — chord in, action out — because that is the direction a key press
//! arrives in.
//!
//! A chord is a key plus the four modifiers, and both halves are stored
//! resolved rather than as the text they were written as: a `Chord` is 8 bytes
//! of plain data with no allocation behind it. That matters more than it
//! sounds, because `Settings` replaces its arena wholesale when the startup
//! list is edited, and anything string-shaped living in there would be freed by
//! an unrelated change (see the note at the top of `Settings.zig`).
//!
//! The key is libghostty's `Key` rather than a GDK keyval so that this file
//! never reaches for GTK: the translation from a keyval lives in `key.zig`,
//! which is the one place that already knows how to do it, and keeping it out
//! of here is what lets the settings tests run without a display.
//!
//! **Every shortcut needs `Ctrl`, `Alt` or `Super`.** A bare key, or one with
//! only `Shift`, belongs to whatever is running in the terminal — claiming `F1`
//! for the window would take it away from every program that has ever drawn a
//! help bar. It is also what keeps ordinary typing cheap: the window's handler
//! sees every key press before the terminal does, and this rule lets it decline
//! in a single test rather than a table search.
const std = @import("std");
const vt = @import("ghostty-vt");
/// A physical key, layout-independent. Named after the W3C code, which is why
/// the letter keys are `key_a` and not `a`.
pub const Key = vt.input.Key;
/// The modifiers a shortcut can ask for.
///
/// Deliberately not libghostty's `KeyMods`, which also carries the lock keys
/// and which side of the keyboard a modifier was pressed on. Neither belongs in
/// a comparison: a shortcut that stopped working because Caps Lock was on would
/// be a bug, and this way it cannot be written.
pub const Mods = struct {
ctrl: bool = false,
alt: bool = false,
shift: bool = false,
super: bool = false,
pub fn eql(a: Mods, b: Mods) bool {
return std.meta.eql(a, b);
}
/// Whether this modifier set is one the window is allowed to claim a key
/// with. See the note at the top of the file.
pub fn claiming(self: Mods) bool {
return self.ctrl or self.alt or self.super;
}
};
pub const Chord = struct {
mods: Mods = .{},
key: Key,
pub fn eql(a: Chord, b: Chord) bool {
return a.key == b.key and a.mods.eql(b.mods);
}
/// The chord written the way `settings.json` accepts it, into `buf`.
///
/// Longest possible output is the four modifiers and the longest key name,
/// which `text_len` is sized for, so this cannot fail.
pub fn text(self: Chord, buf: []u8) []const u8 {
var w: usize = 0;
const parts = [_]struct { bool, []const u8 }{
.{ self.mods.ctrl, "ctrl+" },
.{ self.mods.alt, "alt+" },
.{ self.mods.shift, "shift+" },
.{ self.mods.super, "super+" },
};
for (parts) |p| {
const on, const name = p;
if (!on) continue;
@memcpy(buf[w..][0..name.len], name);
w += name.len;
}
const name = keyName(self.key);
@memcpy(buf[w..][0..name.len], name);
return buf[0 .. w + name.len];
}
};
/// Ample for `ctrl+alt+shift+super+` and the longest key name in the enum.
pub const text_len = 64;
// -------------------------------------------------------------------------
// Actions
/// Everything a shortcut can be pointed at.
///
/// These names are the keys in `settings.json`, so they are part of the file
/// format: renaming one silently drops whatever a user had bound to it.
pub const Action = enum {
new_tab,
close_pane,
new_terminal,
new_web,
rename_tab,
toggle_zoom,
paste,
copy,
find,
open_settings,
prev_tab,
next_tab,
focus_pane_left,
focus_pane_right,
focus_pane_up,
focus_pane_down,
move_pane_left,
move_pane_right,
move_pane_up,
move_pane_down,
select_tab_1,
select_tab_2,
select_tab_3,
select_tab_4,
select_tab_5,
select_tab_6,
select_tab_7,
select_tab_8,
select_last_tab,
};
pub const Binding = struct {
chord: Chord,
action: Action,
};
/// What this build ships with.
///
/// An action may appear more than once — `toggle_zoom` and the two tab-cycling
/// actions each answer to two chords, because the pane-navigation set added
/// later has its own idea of what they should be and the older chords are in
/// people's fingers.
pub const defaults: []const Binding = &.{
.{ .chord = chord("ctrl+shift+t"), .action = .new_tab },
.{ .chord = chord("ctrl+shift+w"), .action = .close_pane },
.{ .chord = chord("ctrl+shift+e"), .action = .new_terminal },
.{ .chord = chord("ctrl+shift+b"), .action = .new_web },
.{ .chord = chord("ctrl+shift+r"), .action = .rename_tab },
.{ .chord = chord("ctrl+shift+z"), .action = .toggle_zoom },
.{ .chord = chord("ctrl+shift+f"), .action = .toggle_zoom },
.{ .chord = chord("ctrl+shift+v"), .action = .paste },
.{ .chord = chord("ctrl+shift+c"), .action = .copy },
.{ .chord = chord("ctrl+f"), .action = .find },
.{ .chord = chord("ctrl+comma"), .action = .open_settings },
.{ .chord = chord("ctrl+page_up"), .action = .prev_tab },
.{ .chord = chord("ctrl+page_down"), .action = .next_tab },
.{ .chord = chord("alt+shift+k"), .action = .prev_tab },
.{ .chord = chord("alt+shift+j"), .action = .next_tab },
.{ .chord = chord("ctrl+shift+h"), .action = .focus_pane_left },
.{ .chord = chord("ctrl+shift+l"), .action = .focus_pane_right },
.{ .chord = chord("ctrl+shift+k"), .action = .focus_pane_up },
.{ .chord = chord("ctrl+shift+j"), .action = .focus_pane_down },
.{ .chord = chord("ctrl+shift+left"), .action = .move_pane_left },
.{ .chord = chord("ctrl+shift+right"), .action = .move_pane_right },
.{ .chord = chord("ctrl+shift+up"), .action = .move_pane_up },
.{ .chord = chord("ctrl+shift+down"), .action = .move_pane_down },
.{ .chord = chord("alt+1"), .action = .select_tab_1 },
.{ .chord = chord("alt+2"), .action = .select_tab_2 },
.{ .chord = chord("alt+3"), .action = .select_tab_3 },
.{ .chord = chord("alt+4"), .action = .select_tab_4 },
.{ .chord = chord("alt+5"), .action = .select_tab_5 },
.{ .chord = chord("alt+6"), .action = .select_tab_6 },
.{ .chord = chord("alt+7"), .action = .select_tab_7 },
.{ .chord = chord("alt+8"), .action = .select_tab_8 },
.{ .chord = chord("alt+9"), .action = .select_last_tab },
};
/// A chord written out in the table above. Compile-time only: a typo in a
/// default is a build failure rather than a shortcut that quietly isn't there.
fn chord(comptime text: []const u8) Chord {
comptime {
const parsed = parse(text) orelse @compileError("not a chord: " ++ text);
return parsed;
}
}
// -------------------------------------------------------------------------
// Overrides
/// How many chords one action can be given. Two is enough for every default
/// here; four leaves room for a keyboard layout that wants its own alongside.
pub const max_chords = 4;
/// The chords bound to one action.
///
/// A fixed array rather than a slice so that overrides need no allocation at
/// all — see the note about the arena at the top of this file.
pub const ChordSet = struct {
buf: [max_chords]Chord = undefined,
len: u8 = 0,
pub fn slice(self: *const ChordSet) []const Chord {
return self.buf[0..self.len];
}
/// Add a chord, or report that this action already has as many as it can
/// hold.
pub fn add(self: *ChordSet, c: Chord) bool {
if (self.len >= max_chords) return false;
self.buf[self.len] = c;
self.len += 1;
return true;
}
};
/// The actions someone has rebound.
///
/// Null for an action means "whatever the defaults say", which is the state
/// anyone who never edits the file stays in — and, as with the palette, it is
/// what lets the shipped chords be changed in a later version and still reach
/// them. An entry that *is* present replaces the defaults for that action
/// entirely, including with nothing at all: a set of length zero is how a
/// shortcut is turned off.
pub const Overrides = std.enums.EnumArray(Action, ?ChordSet);
pub const no_overrides: Overrides = .initFill(null);
pub fn anySet(overrides: *const Overrides) bool {
for (std.enums.values(Action)) |action| {
if (overrides.get(action) != null) return true;
}
return false;
}
/// The chords `action` currently answers to, written into `buf`.
///
/// Small enough to recompute at each call site that wants it; there are two
/// dozen actions and a handful of chords each.
pub fn chordsFor(action: Action, overrides: *const Overrides, buf: *ChordSet) []const Chord {
if (overrides.get(action)) |set| {
buf.* = set;
return buf.slice();
}
buf.* = .{};
for (defaults) |binding| {
if (binding.action != action) continue;
_ = buf.add(binding.chord);
}
return buf.slice();
}
/// What a key press should do, or null to let it through.
///
/// Overrides are searched before the defaults so that rebinding an action onto
/// a chord this build ships for a *different* action does what it looks like it
/// does. Without that, `ctrl+shift+z` given to `new_web` would still zoom,
/// because the default table also names it — the whole point of an override is
/// that it wins.
pub fn actionFor(c: Chord, overrides: *const Overrides) ?Action {
if (!c.mods.claiming()) return null;
for (std.enums.values(Action)) |action| {
const set = overrides.get(action) orelse continue;
for (set.slice()) |bound| {
if (bound.eql(c)) return action;
}
}
for (defaults) |binding| {
if (!binding.chord.eql(c)) continue;
// Rebound elsewhere, so this build's chord for it is no longer in
// force. Kept out here rather than by pre-flattening the two tables so
// that the defaults stay a plain literal anyone can read.
if (overrides.get(binding.action) != null) continue;
return binding.action;
}
return null;
}
// -------------------------------------------------------------------------
// Reading and writing a chord
//
// `ctrl+shift+h`: modifiers in any order, then the key. Case is ignored, as is
// space around the pieces, because this is a hand-edited file and none of that
// is worth a warning.
/// Parse a chord, or null if it isn't one. Both the unknown-name and the
/// no-modifier cases land here so that callers have a single failure to report.
pub fn parse(text: []const u8) ?Chord {
var mods: Mods = .{};
var rest = std.mem.trim(u8, text, " \t");
while (std.mem.indexOfScalar(u8, rest, '+')) |split| {
const name = std.mem.trim(u8, rest[0..split], " \t");
rest = std.mem.trim(u8, rest[split + 1 ..], " \t");
if (eq(name, "ctrl") or eq(name, "control")) {
mods.ctrl = true;
} else if (eq(name, "alt") or eq(name, "option")) {
mods.alt = true;
} else if (eq(name, "shift")) {
mods.shift = true;
} else if (eq(name, "super") or eq(name, "meta") or eq(name, "win") or eq(name, "cmd")) {
mods.super = true;
} else {
return null;
}
}
if (!mods.claiming()) return null;
return .{ .mods = mods, .key = keyFromName(rest) orelse return null };
}
fn eq(a: []const u8, b: []const u8) bool {
return std.ascii.eqlIgnoreCase(a, b);
}
/// The key enum is named after the W3C codes, which spell the three families
/// someone would write as a bare word with a prefix. Stripping those is the
/// whole of the naming scheme, in both directions: `key_h` is `h`, `digit_1`
/// is `1`, `arrow_left` is `left`, and everything else — `page_up`, `escape`,
/// `f5`, `comma` — is already what you would type.
const prefixes = [_][]const u8{ "key_", "digit_", "arrow_" };
fn keyFromName(name: []const u8) ?Key {
if (name.len == 0 or name.len >= 32) return null;
var lower_buf: [32]u8 = undefined;
const lower = std.ascii.lowerString(lower_buf[0..name.len], name);
var tag_buf: [40]u8 = undefined;
const tag: []const u8 = tag: {
if (lower.len == 1) {
if (std.ascii.isAlphabetic(lower[0])) {
break :tag std.fmt.bufPrint(&tag_buf, "key_{s}", .{lower}) catch return null;
}
if (std.ascii.isDigit(lower[0])) {
break :tag std.fmt.bufPrint(&tag_buf, "digit_{s}", .{lower}) catch return null;
}
}
for ([_][]const u8{ "left", "right", "up", "down" }) |direction| {
if (!std.mem.eql(u8, lower, direction)) continue;
break :tag std.fmt.bufPrint(&tag_buf, "arrow_{s}", .{lower}) catch return null;
}
break :tag alias(lower);
};
const key = std.meta.stringToEnum(Key, tag) orelse return null;
// The enum's own name for "we could not tell", which is not something to
// bind to and not something `key.zig` ever hands back.
if (key == .unidentified) return null;
return key;
}
/// Spellings that aren't the enum's but that someone will reasonably write.
/// The canonical name is what gets written back out, so these are read-only.
fn alias(name: []const u8) []const u8 {
const table = [_]struct { []const u8, []const u8 }{
.{ "esc", "escape" },
.{ "return", "enter" },
.{ "pageup", "page_up" },
.{ "pgup", "page_up" },
.{ "pagedown", "page_down" },
.{ "pgdn", "page_down" },
.{ "del", "delete" },
.{ "ins", "insert" },
.{ "bracketleft", "bracket_left" },
.{ "bracketright", "bracket_right" },
};
for (table) |entry| {
const from, const to = entry;
if (std.mem.eql(u8, name, from)) return to;
}
return name;
}
fn keyName(key: Key) []const u8 {
const tag = @tagName(key);
for (prefixes) |prefix| {
if (std.mem.startsWith(u8, tag, prefix)) return tag[prefix.len..];
}
return tag;
}
// -------------------------------------------------------------------------
// Tests
fn named(name: []const u8) Key {
return keyFromName(name).?;
}
test "a chord is modifiers in any order and a key" {
try std.testing.expectEqual(
Chord{ .mods = .{ .ctrl = true, .shift = true }, .key = named("h") },
parse("ctrl+shift+h").?,
);
try std.testing.expectEqual(
parse("ctrl+shift+h").?,
parse("Shift + CTRL + H").?,
);
try std.testing.expectEqual(
Chord{ .mods = .{ .alt = true }, .key = named("1") },
parse("alt+1").?,
);
}
test "named keys, their aliases, and the arrows" {
try std.testing.expectEqual(Key.page_up, parse("ctrl+page_up").?.key);
try std.testing.expectEqual(Key.page_up, parse("ctrl+PgUp").?.key);
try std.testing.expectEqual(Key.arrow_left, parse("ctrl+left").?.key);
try std.testing.expectEqual(Key.comma, parse("ctrl+comma").?.key);
try std.testing.expectEqual(Key.f5, parse("ctrl+f5").?.key);
}
test "a chord the window may not claim is not a chord" {
// Nothing but Shift leaves the key to whatever is running in the terminal.
try std.testing.expectEqual(@as(?Chord, null), parse("h"));
try std.testing.expectEqual(@as(?Chord, null), parse("shift+h"));
// And neither a modifier nor a key we know is one we can bind.
try std.testing.expectEqual(@as(?Chord, null), parse("hyper+h"));
try std.testing.expectEqual(@as(?Chord, null), parse("ctrl+nonesuch"));
try std.testing.expectEqual(@as(?Chord, null), parse("ctrl+"));
}
test "a chord survives being written out and read back" {
for (defaults) |binding| {
var buf: [text_len]u8 = undefined;
const text = binding.chord.text(&buf);
try std.testing.expectEqual(binding.chord, parse(text).?);
}
}
test "written out, a chord reads the way it would be typed" {
var buf: [text_len]u8 = undefined;
try std.testing.expectEqualStrings(
"ctrl+shift+h",
(Chord{ .mods = .{ .ctrl = true, .shift = true }, .key = named("h") }).text(&buf),
);
try std.testing.expectEqualStrings(
"ctrl+page_up",
(Chord{ .mods = .{ .ctrl = true }, .key = .page_up }).text(&buf),
);
try std.testing.expectEqualStrings(
"alt+shift+j",
(Chord{ .mods = .{ .alt = true, .shift = true }, .key = named("j") }).text(&buf),
);
}
test "the navigation defaults are bound where they were asked for" {
const overrides = no_overrides;
const cases = [_]struct { []const u8, Action }{
.{ "ctrl+shift+h", .focus_pane_left },
.{ "ctrl+shift+j", .focus_pane_down },
.{ "ctrl+shift+k", .focus_pane_up },
.{ "ctrl+shift+l", .focus_pane_right },
.{ "alt+shift+j", .next_tab },
.{ "alt+shift+k", .prev_tab },
.{ "ctrl+shift+f", .toggle_zoom },
.{ "ctrl+shift+z", .toggle_zoom },
};
for (cases) |c| {
const text, const action = c;
try std.testing.expectEqual(action, actionFor(parse(text).?, &overrides).?);
}
}
test "no two defaults claim the same chord" {
for (defaults, 0..) |a, i| {
for (defaults[i + 1 ..]) |b| {
if (!a.chord.eql(b.chord)) continue;
std.debug.print("{s} and {s} share a chord\n", .{ @tagName(a.action), @tagName(b.action) });
return error.DuplicateDefault;
}
}
}
test "an override replaces an action's defaults rather than adding to them" {
var overrides = no_overrides;
var set: ChordSet = .{};
try std.testing.expect(set.add(parse("ctrl+alt+n").?));
overrides.set(.new_tab, set);
try std.testing.expectEqual(Action.new_tab, actionFor(parse("ctrl+alt+n").?, &overrides).?);
try std.testing.expectEqual(@as(?Action, null), actionFor(parse("ctrl+shift+t").?, &overrides));
// Everything else is left where the defaults put it.
try std.testing.expectEqual(Action.close_pane, actionFor(parse("ctrl+shift+w").?, &overrides).?);
}
test "an override wins the chord from whichever default had it" {
var overrides = no_overrides;
var set: ChordSet = .{};
try std.testing.expect(set.add(parse("ctrl+shift+z").?));
overrides.set(.new_web, set);
try std.testing.expectEqual(Action.new_web, actionFor(parse("ctrl+shift+z").?, &overrides).?);
// Zoom keeps the second chord it ships with, having not been rebound.
try std.testing.expectEqual(Action.toggle_zoom, actionFor(parse("ctrl+shift+f").?, &overrides).?);
}
test "an empty override turns a shortcut off" {
var overrides = no_overrides;
overrides.set(.new_tab, .{});
try std.testing.expectEqual(@as(?Action, null), actionFor(parse("ctrl+shift+t").?, &overrides));
}
test "chordsFor reports the defaults until something replaces them" {
var overrides = no_overrides;
var buf: ChordSet = .{};
const shipped = chordsFor(.toggle_zoom, &overrides, &buf);
try std.testing.expectEqual(@as(usize, 2), shipped.len);
try std.testing.expectEqual(parse("ctrl+shift+z").?, shipped[0]);
var set: ChordSet = .{};
try std.testing.expect(set.add(parse("ctrl+alt+z").?));
overrides.set(.toggle_zoom, set);
const bound = chordsFor(.toggle_zoom, &overrides, &buf);
try std.testing.expectEqual(@as(usize, 1), bound.len);
try std.testing.expectEqual(parse("ctrl+alt+z").?, bound[0]);
}