Add better and configurable shortcuts.
This commit is contained in:
@@ -403,8 +403,12 @@ in principle, but a terminal grid is small.
|
||||
right-click menu, picked out of the full emoji set with keyword search. See
|
||||
[Tab settings](#tab-settings)
|
||||
- **Zooming a pane** to fill its tab and back, from the header button or
|
||||
`Ctrl+Shift+Z`. Nothing closes and nothing moves — hidden panes keep running
|
||||
and the split comes back exactly as it was. See [Zoom](#zoom)
|
||||
`Ctrl+Shift+Z` / `Ctrl+Shift+F`. Nothing closes and nothing moves — hidden
|
||||
panes keep running and the split comes back exactly as it was. See [Zoom](#zoom)
|
||||
- **Moving around a split from the keyboard**: `Ctrl+Shift+H/J/K/L` moves focus
|
||||
to the pane in that direction, `Alt+Shift+J/K` steps through tabs. Every chord
|
||||
in the app can be rebound in `settings.json`. See
|
||||
[Rebinding a shortcut](#rebinding-a-shortcut)
|
||||
- **Light and dark schemes**, following the desktop by default and pinnable
|
||||
from the settings page, applying to open tabs immediately — terminal palette
|
||||
included. See [Theme](#theme)
|
||||
@@ -416,21 +420,26 @@ in principle, but a terminal grid is small.
|
||||
|
||||
### Shortcuts
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `Ctrl+Shift+T` | new tab |
|
||||
| `Ctrl+Shift+E` | new terminal in the current tab |
|
||||
| `Ctrl+Shift+B` | new web view in the current tab |
|
||||
| `Ctrl+Shift+W` | close the focused pane (closes the tab with its last one) |
|
||||
| `Ctrl+Shift+←/→/↑/↓` | move the focused pane within its view |
|
||||
| `Ctrl+Shift+V` | paste into a terminal (bracketed-paste aware, refuses unsafe pastes) |
|
||||
| `Ctrl+Shift+C` | copy the terminal selection |
|
||||
| `Ctrl+Shift+R` | rename the current tab (empty name = follow the terminal) |
|
||||
| `Ctrl+Shift+Z` | zoom the focused pane to fill the tab, and back |
|
||||
| `Ctrl+F` | find in the focused web page (web panes only — see [Find in page](#find-in-page)) |
|
||||
| `Ctrl+,` | settings |
|
||||
| `Ctrl+PageUp/PageDown` | previous / next tab |
|
||||
| `Alt+1`..`Alt+8` | jump to tab N, `Alt+9` jumps to the last |
|
||||
| | | |
|
||||
|---|---|---|
|
||||
| `Ctrl+Shift+T` | new tab | `new_tab` |
|
||||
| `Ctrl+Shift+E` | new terminal in the current tab | `new_terminal` |
|
||||
| `Ctrl+Shift+B` | new web view in the current tab | `new_web` |
|
||||
| `Ctrl+Shift+W` | close the focused pane (closes the tab with its last one) | `close_pane` |
|
||||
| `Ctrl+Shift+H/J/K/L` | move focus to the pane left / below / above / right | `focus_pane_left` and friends |
|
||||
| `Ctrl+Shift+←/→/↑/↓` | move the focused pane within its view | `move_pane_left` and friends |
|
||||
| `Ctrl+Shift+V` | paste into a terminal (bracketed-paste aware, refuses unsafe pastes) | `paste` |
|
||||
| `Ctrl+Shift+C` | copy the terminal selection | `copy` |
|
||||
| `Ctrl+Shift+R` | rename the current tab (empty name = follow the terminal) | `rename_tab` |
|
||||
| `Ctrl+Shift+Z`, `Ctrl+Shift+F` | zoom the focused pane to fill the tab, and back | `toggle_zoom` |
|
||||
| `Ctrl+F` | find in the focused web page (web panes only — see [Find in page](#find-in-page)) | `find` |
|
||||
| `Ctrl+,` | settings | `open_settings` |
|
||||
| `Ctrl+PageUp/PageDown`, `Alt+Shift+K/J` | previous / next tab | `prev_tab`, `next_tab` |
|
||||
| `Alt+1`..`Alt+8` | jump to tab N | `select_tab_1`.. |
|
||||
| `Alt+9` | jump to the last tab | `select_last_tab` |
|
||||
|
||||
The third column is the action's name in `settings.json`, which is where any of
|
||||
these can be changed — see [Rebinding a shortcut](#rebinding-a-shortcut).
|
||||
|
||||
Dragging a pane by its header does the same thing as the arrow shortcuts: the
|
||||
edge you drop against decides both the order and the view's orientation. The
|
||||
@@ -440,6 +449,60 @@ competes with the content's own mouse handling.
|
||||
Both add-pane shortcuts are also buttons in every pane header, which is how you
|
||||
open a web view without remembering the chord.
|
||||
|
||||
### Moving around a split
|
||||
|
||||
`Ctrl+Shift+H/J/K/L` moves focus between panes; the arrow keys on the same
|
||||
modifiers move the *pane* instead. Which pane is "the one to the left" is
|
||||
answered by where the panes are on screen rather than by walking the split tree,
|
||||
so it means what it looks like — a tree walk would have to choose between a
|
||||
sibling and a cousin, and on screen there is no such distinction.
|
||||
|
||||
Nothing happens at the edges of a view, and the key is swallowed rather than
|
||||
passed on: the chord was bound for navigating, and handing it to the shell in
|
||||
the last pane of a split would send a control code nobody asked for. Zoom is the
|
||||
other case where focus stays put — the other panes are not on screen, and the
|
||||
arrangement is exactly what zoom is hiding.
|
||||
|
||||
### Rebinding a shortcut
|
||||
|
||||
Every chord in the table above is a default, not a fixture. A `shortcuts` object
|
||||
in `settings.json` names the ones to change:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"shortcuts": {
|
||||
"focus_pane_left": "ctrl+alt+h",
|
||||
"toggle_zoom": ["ctrl+shift+z", "super+f"],
|
||||
"copy": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
A value is one chord or a list of them, and an entry **replaces** that action's
|
||||
defaults rather than adding to them — which is what lets a chord be moved from
|
||||
one action to another without the old owner still answering to it. An empty list
|
||||
switches a shortcut off entirely and hands the key back to whatever is running
|
||||
in the terminal. An action the file doesn't mention keeps the chords this build
|
||||
ships with, so a later version can retune the defaults and still reach anyone
|
||||
who never touched them.
|
||||
|
||||
A chord is modifiers and a key, `+`-separated, in any order and in any case:
|
||||
`ctrl`, `alt`, `shift`, `super` (`control`, `option`, `meta`, `win` and `cmd`
|
||||
also read), then a key named the way you would say it — `h`, `7`, `left`,
|
||||
`page_up`, `f5`, `escape`, `space`, `comma`, `bracket_left`.
|
||||
|
||||
**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 from every program that has ever drawn a help bar. A chord
|
||||
that doesn't parse, or an action name this build doesn't have, costs that one
|
||||
binding and is logged; the rest of the file is read normally.
|
||||
|
||||
The file is rewritten whole every time a setting changes, so what you write here
|
||||
comes back out of the settings page unharmed — in the app's own canonical
|
||||
spelling, which is why `Shift + CTRL + H` is read once and written back as
|
||||
`ctrl+shift+h`.
|
||||
|
||||
## The mouse in a terminal
|
||||
|
||||
A terminal's pointer has two possible owners, and which one has it depends on
|
||||
@@ -468,9 +531,9 @@ anyway.
|
||||
## Zoom
|
||||
|
||||
A split that was the right shape for watching two things at once is usually the
|
||||
wrong shape for actually working in one of them. `Ctrl+Shift+Z`, or the
|
||||
fullscreen button in a pane's header, gives that pane the whole tab; the same
|
||||
again puts the split back.
|
||||
wrong shape for actually working in one of them. `Ctrl+Shift+Z` or
|
||||
`Ctrl+Shift+F`, or the fullscreen button in a pane's header, gives that pane the
|
||||
whole tab; the same again puts the split back.
|
||||
|
||||
This is not the window manager's fullscreen. The window keeps its decorations,
|
||||
the sidebar stays put, and only the tab's own content area is involved.
|
||||
|
||||
@@ -115,6 +115,21 @@ pub fn build(b: *std.Build) void {
|
||||
settings_tests.root_module.addImport("ghostty-vt", ghostty.module("ghostty-vt"));
|
||||
test_step.dependOn(&b.addRunArtifact(settings_tests).step);
|
||||
|
||||
// The shortcut table is its own root for the same reason: chords are parsed
|
||||
// from text, written back out as text, and looked up by a key press, and
|
||||
// all three are pure data. It reaches libghostty-vt for the key enum and
|
||||
// nothing else, which is what keeps it — and the settings tests above, which
|
||||
// import it — off GTK.
|
||||
const shortcut_tests = b.addTest(.{
|
||||
.root_module = b.createModule(.{
|
||||
.root_source_file = b.path("src/shortcuts.zig"),
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
}),
|
||||
});
|
||||
shortcut_tests.root_module.addImport("ghostty-vt", ghostty.module("ghostty-vt"));
|
||||
test_step.dependOn(&b.addRunArtifact(shortcut_tests).step);
|
||||
|
||||
// A second root for the same reason, one step further out: `emoji.zig` is a
|
||||
// table and a search over it, and it imports nothing at all. It cannot hang
|
||||
// off the root above because a test binary has exactly one root, and
|
||||
|
||||
+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();
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
Reference in New Issue
Block a user