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
|
right-click menu, picked out of the full emoji set with keyword search. See
|
||||||
[Tab settings](#tab-settings)
|
[Tab settings](#tab-settings)
|
||||||
- **Zooming a pane** to fill its tab and back, from the header button or
|
- **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
|
`Ctrl+Shift+Z` / `Ctrl+Shift+F`. Nothing closes and nothing moves — hidden
|
||||||
and the split comes back exactly as it was. See [Zoom](#zoom)
|
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
|
- **Light and dark schemes**, following the desktop by default and pinnable
|
||||||
from the settings page, applying to open tabs immediately — terminal palette
|
from the settings page, applying to open tabs immediately — terminal palette
|
||||||
included. See [Theme](#theme)
|
included. See [Theme](#theme)
|
||||||
@@ -416,21 +420,26 @@ in principle, but a terminal grid is small.
|
|||||||
|
|
||||||
### Shortcuts
|
### Shortcuts
|
||||||
|
|
||||||
| | |
|
| | | |
|
||||||
|---|---|
|
|---|---|---|
|
||||||
| `Ctrl+Shift+T` | new tab |
|
| `Ctrl+Shift+T` | new tab | `new_tab` |
|
||||||
| `Ctrl+Shift+E` | new terminal in the current tab |
|
| `Ctrl+Shift+E` | new terminal in the current tab | `new_terminal` |
|
||||||
| `Ctrl+Shift+B` | new web view in the current tab |
|
| `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) |
|
| `Ctrl+Shift+W` | close the focused pane (closes the tab with its last one) | `close_pane` |
|
||||||
| `Ctrl+Shift+←/→/↑/↓` | move the focused pane within its view |
|
| `Ctrl+Shift+H/J/K/L` | move focus to the pane left / below / above / right | `focus_pane_left` and friends |
|
||||||
| `Ctrl+Shift+V` | paste into a terminal (bracketed-paste aware, refuses unsafe pastes) |
|
| `Ctrl+Shift+←/→/↑/↓` | move the focused pane within its view | `move_pane_left` and friends |
|
||||||
| `Ctrl+Shift+C` | copy the terminal selection |
|
| `Ctrl+Shift+V` | paste into a terminal (bracketed-paste aware, refuses unsafe pastes) | `paste` |
|
||||||
| `Ctrl+Shift+R` | rename the current tab (empty name = follow the terminal) |
|
| `Ctrl+Shift+C` | copy the terminal selection | `copy` |
|
||||||
| `Ctrl+Shift+Z` | zoom the focused pane to fill the tab, and back |
|
| `Ctrl+Shift+R` | rename the current tab (empty name = follow the terminal) | `rename_tab` |
|
||||||
| `Ctrl+F` | find in the focused web page (web panes only — see [Find in page](#find-in-page)) |
|
| `Ctrl+Shift+Z`, `Ctrl+Shift+F` | zoom the focused pane to fill the tab, and back | `toggle_zoom` |
|
||||||
| `Ctrl+,` | settings |
|
| `Ctrl+F` | find in the focused web page (web panes only — see [Find in page](#find-in-page)) | `find` |
|
||||||
| `Ctrl+PageUp/PageDown` | previous / next tab |
|
| `Ctrl+,` | settings | `open_settings` |
|
||||||
| `Alt+1`..`Alt+8` | jump to tab N, `Alt+9` jumps to the last |
|
| `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
|
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
|
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
|
Both add-pane shortcuts are also buttons in every pane header, which is how you
|
||||||
open a web view without remembering the chord.
|
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
|
## The mouse in a terminal
|
||||||
|
|
||||||
A terminal's pointer has two possible owners, and which one has it depends on
|
A terminal's pointer has two possible owners, and which one has it depends on
|
||||||
@@ -468,9 +531,9 @@ anyway.
|
|||||||
## Zoom
|
## Zoom
|
||||||
|
|
||||||
A split that was the right shape for watching two things at once is usually the
|
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
|
wrong shape for actually working in one of them. `Ctrl+Shift+Z` or
|
||||||
fullscreen button in a pane's header, gives that pane the whole tab; the same
|
`Ctrl+Shift+F`, or the fullscreen button in a pane's header, gives that pane the
|
||||||
again puts the split back.
|
whole tab; the same again puts the split back.
|
||||||
|
|
||||||
This is not the window manager's fullscreen. The window keeps its decorations,
|
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.
|
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"));
|
settings_tests.root_module.addImport("ghostty-vt", ghostty.module("ghostty-vt"));
|
||||||
test_step.dependOn(&b.addRunArtifact(settings_tests).step);
|
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
|
// 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
|
// 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
|
// 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
|
//! 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
|
//! array of optionals, which needs no allocation at all — and, more to the
|
||||||
//! point, `setStartup` replaces the arena wholesale, so anything colour-shaped
|
//! 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
|
//! Because saving rewrites the whole file, there is exactly one `Settings` for
|
||||||
//! the process, reached through `get`. Two holders each convinced they knew
|
//! the process, reached through `get`. Two holders each convinced they knew
|
||||||
@@ -32,6 +35,7 @@ const std = @import("std");
|
|||||||
const glib = @import("glib");
|
const glib = @import("glib");
|
||||||
|
|
||||||
const palette = @import("palette.zig");
|
const palette = @import("palette.zig");
|
||||||
|
const shortcuts = @import("shortcuts.zig");
|
||||||
|
|
||||||
const Settings = @This();
|
const Settings = @This();
|
||||||
|
|
||||||
@@ -130,6 +134,11 @@ colors: Colors = .{},
|
|||||||
/// which is what the app did before this setting existed.
|
/// which is what the app did before this setting existed.
|
||||||
startup: []const StartupTab = &.{},
|
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
|
// 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("colors")) |value| self.parseColors(value);
|
||||||
|
|
||||||
|
if (root.get("shortcuts")) |value| self.parseShortcuts(value);
|
||||||
|
|
||||||
if (root.get("startup")) |value| try self.parseStartup(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.
|
/// Read the startup list.
|
||||||
///
|
///
|
||||||
/// Entries that make no sense are skipped rather than failing the file. The
|
/// 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();
|
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
|
// Written even when empty, so that the key is discoverable in a file
|
||||||
// someone has opened to see what they can put in it.
|
// someone has opened to see what they can put in it.
|
||||||
try json.objectField("startup");
|
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" {
|
test "a scheme with nothing changed is left out of the file entirely" {
|
||||||
var settings = forTesting();
|
var settings = forTesting();
|
||||||
defer settings.arena.deinit();
|
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);
|
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
|
/// 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.
|
/// just past the pane's edge and asking which pane covers that point.
|
||||||
fn neighbor(self: *View, pane: *Pane, side: Side) ?*Pane {
|
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 View = @import("View.zig");
|
||||||
const appearance = @import("appearance.zig");
|
const appearance = @import("appearance.zig");
|
||||||
const emoji = @import("emoji.zig");
|
const emoji = @import("emoji.zig");
|
||||||
|
const key = @import("key.zig");
|
||||||
|
const shortcuts = @import("shortcuts.zig");
|
||||||
|
|
||||||
const Window = @This();
|
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
|
// Window-level shortcuts run in the capture phase so they are handled
|
||||||
// before the focused terminal turns the key into a VT sequence.
|
// before the focused terminal turns the key into a VT sequence.
|
||||||
const shortcuts = gtk.EventControllerKey.new();
|
const keys = gtk.EventControllerKey.new();
|
||||||
shortcuts.as(gtk.EventController).setPropagationPhase(.capture);
|
keys.as(gtk.EventController).setPropagationPhase(.capture);
|
||||||
_ = gtk.EventControllerKey.signals.key_pressed.connect(
|
_ = gtk.EventControllerKey.signals.key_pressed.connect(
|
||||||
shortcuts,
|
keys,
|
||||||
*Window,
|
*Window,
|
||||||
&onShortcut,
|
&onShortcut,
|
||||||
self,
|
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
|
// Free our own state once GTK is done with the window. Doing this on
|
||||||
// `destroy` rather than `close-request` means no further events can
|
// `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));
|
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(
|
fn onShortcut(
|
||||||
_: *gtk.EventControllerKey,
|
_: *gtk.EventControllerKey,
|
||||||
keyval: c_uint,
|
keyval: c_uint,
|
||||||
@@ -1610,121 +1623,116 @@ fn onShortcut(
|
|||||||
state: gdk.ModifierType,
|
state: gdk.ModifierType,
|
||||||
self: *Window,
|
self: *Window,
|
||||||
) callconv(.c) c_int {
|
) callconv(.c) c_int {
|
||||||
const ctrl = state.control_mask;
|
const mods: shortcuts.Mods = .{
|
||||||
const shift = state.shift_mask;
|
.ctrl = state.control_mask,
|
||||||
const alt = state.alt_mask;
|
.alt = state.alt_mask,
|
||||||
|
.shift = state.shift_mask,
|
||||||
|
.super = state.super_mask,
|
||||||
|
};
|
||||||
|
|
||||||
if (ctrl and shift) {
|
// Every shortcut needs one of these, so ordinary typing — which arrives
|
||||||
switch (keyval) {
|
// here first, on every key — is declined before anything is looked up.
|
||||||
gdk.KEY_T, gdk.KEY_t => {
|
if (!mods.claiming()) return 0;
|
||||||
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 => {},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ctrl+Shift+arrows rearrange the focused terminal within its view. This
|
// Shift turns the letter keys into their capitals, and a chord is written
|
||||||
// is the keyboard route to the same rearranging that dragging a pane's
|
// as the key you press rather than the character it produces.
|
||||||
// header does.
|
const key_val = key.keyFromKeyval(gdk.keyvalToLower(keyval)) orelse return 0;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chords without Shift, which have to be the ones a terminal doesn't want
|
const action = shortcuts.actionFor(
|
||||||
// for itself. Ctrl+PageUp/PageDown cycles tabs, matching most tabbed
|
.{ .mods = mods, .key = key_val },
|
||||||
// terminals; Ctrl+comma opens settings, which is the convention nearly
|
&Settings.get().keys,
|
||||||
// everywhere; Ctrl+F is claimed only over a web pane.
|
) orelse return 0;
|
||||||
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 => {},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Alt+1..9 jumps straight to a tab; Alt+9 is "last tab" by convention.
|
return if (self.perform(action)) 1 else 0;
|
||||||
if (alt and !ctrl) {
|
}
|
||||||
if (keyval >= gdk.KEY_1 and keyval <= gdk.KEY_9) {
|
|
||||||
const n = keyval - gdk.KEY_1;
|
/// Run one action. Returns whether the key was used, which is not the same as
|
||||||
if (n == 8) {
|
/// whether anything happened: an action that has nothing to act on here — copy
|
||||||
self.selectIndex(self.tabs.items.len -| 1);
|
/// with no selection, find outside a web pane — declines the key so that
|
||||||
} else {
|
/// whatever is focused gets it instead, while one that simply had nowhere to go
|
||||||
self.selectIndex(@intCast(n));
|
/// still swallows it rather than sending a stray control code to a shell.
|
||||||
}
|
fn perform(self: *Window, action: shortcuts.Action) bool {
|
||||||
return 1;
|
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