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