Add better and configurable shortcuts.

This commit is contained in:
Greyson Parrelli committed 2026-08-15 00:00:06 -04:00
1 parent 043b994a15
commit a742b51d3b
6 files changed
+987 -136

No files matched your search

+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();