Add better and configurable shortcuts.

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