Add better and configurable shortcuts.
This commit is contained in:
1 parent
043b994a15
commit
a742b51d3b
6 files changed
+987
-136
No files matched your search
+198
-1
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user