From 63422f4d4e10a9c78fe7162bf784689cf226bada Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 19 Dec 2025 11:56:04 -0800 Subject: [PATCH] add the catch_all binding key Part of #9963 This adds a new special key `catch_all` that can be used in keybinding definitions to match any key that is not explicitly bound. For example: `keybind = catch_all=new_window` (chaos!). `catch_all` can be used in combination with modifiers, so if you want to catch any non-bound key with Ctrl held down, you can do: `keybind = ctrl+catch_all=new_window`. `catch_all` can also be used with trigger sequences, so you can do: `keybind = ctrl+a>catch_all=new_window` to catch any key pressed after `ctrl+a` that is not explicitly bound and make a new window! And if you want to remove the catch all binding, it is like any other: `keybind = catch_all=unbind`. --- include/ghostty.h | 2 + macos/Sources/Ghostty/Ghostty.Input.swift | 4 + src/apprt/gtk/key.zig | 2 + src/cli/list_keybinds.zig | 5 + src/config/Config.zig | 7 ++ src/input/Binding.zig | 129 +++++++++++++++++++++- 6 files changed, 148 insertions(+), 1 deletion(-) diff --git a/include/ghostty.h b/include/ghostty.h index 47db34e71..736c7546b 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -317,12 +317,14 @@ typedef struct { typedef enum { GHOSTTY_TRIGGER_PHYSICAL, GHOSTTY_TRIGGER_UNICODE, + GHOSTTY_TRIGGER_CATCH_ALL, } ghostty_input_trigger_tag_e; typedef union { ghostty_input_key_e translated; ghostty_input_key_e physical; uint32_t unicode; + // catch_all has no payload } ghostty_input_trigger_key_u; typedef struct { diff --git a/macos/Sources/Ghostty/Ghostty.Input.swift b/macos/Sources/Ghostty/Ghostty.Input.swift index e05911c06..6b4eb0ae4 100644 --- a/macos/Sources/Ghostty/Ghostty.Input.swift +++ b/macos/Sources/Ghostty/Ghostty.Input.swift @@ -32,6 +32,10 @@ extension Ghostty { guard let scalar = UnicodeScalar(trigger.key.unicode) else { return nil } key = KeyEquivalent(Character(scalar)) + case GHOSTTY_TRIGGER_CATCH_ALL: + // catch_all matches any key, so it can't be represented as a KeyboardShortcut + return nil + default: return nil } diff --git a/src/apprt/gtk/key.zig b/src/apprt/gtk/key.zig index 19bdc8315..35c9390b2 100644 --- a/src/apprt/gtk/key.zig +++ b/src/apprt/gtk/key.zig @@ -74,6 +74,8 @@ fn writeTriggerKey( try writer.print("{u}", .{cp}); } }, + + .catch_all => return false, } return true; diff --git a/src/cli/list_keybinds.zig b/src/cli/list_keybinds.zig index a8899a4f5..e463f55b9 100644 --- a/src/cli/list_keybinds.zig +++ b/src/cli/list_keybinds.zig @@ -166,16 +166,19 @@ const ChordBinding = struct { var r_trigger = rhs.triggers.first; while (l_trigger != null and r_trigger != null) { + // We want catch_all to sort last. const lhs_key: c_int = blk: { switch (TriggerNode.get(l_trigger.?).data.key) { .physical => |key| break :blk @intFromEnum(key), .unicode => |key| break :blk @intCast(key), + .catch_all => break :blk std.math.maxInt(c_int), } }; const rhs_key: c_int = blk: { switch (TriggerNode.get(r_trigger.?).data.key) { .physical => |key| break :blk @intFromEnum(key), .unicode => |key| break :blk @intCast(key), + .catch_all => break :blk std.math.maxInt(c_int), } }; @@ -268,6 +271,7 @@ fn prettyPrint(alloc: Allocator, keybinds: Config.Keybinds) !u8 { const key = switch (trigger.data.key) { .physical => |k| try std.fmt.allocPrint(alloc, "{t}", .{k}), .unicode => |c| try std.fmt.allocPrint(alloc, "{u}", .{c}), + .catch_all => "catch_all", }; result = win.printSegment(.{ .text = key }, .{ .col_offset = result.col }); @@ -314,6 +318,7 @@ fn iterateBindings( switch (t.key) { .physical => |k| try buf.writer.print("{t}", .{k}), .unicode => |c| try buf.writer.print("{u}", .{c}), + .catch_all => try buf.writer.print("catch_all", .{}), } break :blk win.gwidth(buf.written()); diff --git a/src/config/Config.zig b/src/config/Config.zig index 1aad62d7d..f2f6f2322 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1477,6 +1477,13 @@ class: ?[:0]const u8 = null, /// so if you specify both `a` and `KeyA`, the physical key will always be used /// regardless of what order they are configured. /// +/// The special key `catch_all` can be used to match any key that is not +/// otherwise bound. This can be combined with modifiers, for example +/// `ctrl+catch_all` will match any key pressed with `ctrl` that is not +/// otherwise bound. When looking up a binding, Ghostty first tries to match +/// `catch_all` with modifiers. If no match is found and the event has +/// modifiers, it falls back to `catch_all` without modifiers. +/// /// Valid modifiers are `shift`, `ctrl` (alias: `control`), `alt` (alias: `opt`, /// `option`), and `super` (alias: `cmd`, `command`). You may use the modifier /// or the alias. When debugging keybinds, the non-aliased modifier will always diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 9f3ad8a2a..666852094 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -1505,6 +1505,10 @@ pub const Trigger = struct { /// codepoint. This is useful for binding to keys that don't have a /// registered keycode with Ghostty. unicode: u21, + + /// A catch-all key that matches any key press that is otherwise + /// unbound. + catch_all, }; /// The extern struct used for triggers in the C API. @@ -1516,6 +1520,7 @@ pub const Trigger = struct { pub const Tag = enum(c_int) { physical, unicode, + catch_all, }; pub const Key = extern union { @@ -1611,6 +1616,13 @@ pub const Trigger = struct { continue :loop; } + // Check for catch_all. We do this near the end since its unlikely + // in most cases that we're setting a catch-all key. + if (std.mem.eql(u8, part, "catch_all")) { + result.key = .catch_all; + continue :loop; + } + // If we're still unset then we look for backwards compatible // keys with Ghostty 1.1.x. We do this last so its least likely // to impact performance for modern users. @@ -1751,7 +1763,7 @@ pub const Trigger = struct { pub fn isKeyUnset(self: Trigger) bool { return switch (self.key) { .physical => |v| v == .unidentified, - else => false, + .unicode, .catch_all => false, }; } @@ -1771,6 +1783,7 @@ pub const Trigger = struct { hasher, foldedCodepoint(cp), ), + .catch_all => {}, } std.hash.autoHash(hasher, self.mods.binding()); } @@ -1801,6 +1814,9 @@ pub const Trigger = struct { .key = switch (self.key) { .physical => |v| .{ .physical = v }, .unicode => |v| .{ .unicode = @intCast(v) }, + // catch_all has no associated value so its an error + // for a C consumer to look at it. + .catch_all => undefined, }, .mods = self.mods, }; @@ -1821,6 +1837,7 @@ pub const Trigger = struct { switch (self.key) { .physical => |k| try writer.print("{t}", .{k}), .unicode => |c| try writer.print("{u}", .{c}), + .catch_all => try writer.writeAll("catch_all"), } } }; @@ -2213,6 +2230,14 @@ pub const Set = struct { if (self.get(trigger)) |v| return v; } + // Fallback to catch_all with modifiers first, then without modifiers. + trigger.key = .catch_all; + if (self.get(trigger)) |v| return v; + if (!trigger.mods.empty()) { + trigger.mods = .{}; + if (self.get(trigger)) |v| return v; + } + return null; } @@ -2433,6 +2458,31 @@ test "parse: w3c key names" { try testing.expectError(Error.InvalidFormat, parseSingle("Keya=ignore")); } +test "parse: catch_all" { + const testing = std.testing; + + // Basic catch_all + try testing.expectEqual( + Binding{ + .trigger = .{ .key = .catch_all }, + .action = .{ .ignore = {} }, + }, + try parseSingle("catch_all=ignore"), + ); + + // catch_all with modifiers + try testing.expectEqual( + Binding{ + .trigger = .{ + .mods = .{ .ctrl = true }, + .key = .catch_all, + }, + .action = .{ .ignore = {} }, + }, + try parseSingle("ctrl+catch_all=ignore"), + ); +} + test "parse: plus sign" { const testing = std.testing; @@ -3329,6 +3379,83 @@ test "set: getEvent codepoint case folding" { } } +test "set: getEvent catch_all fallback" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "catch_all=ignore"); + + // Matches unbound key without modifiers + { + const action = s.getEvent(.{ + .key = .key_a, + .mods = .{}, + }).?.value_ptr.*.leaf; + try testing.expect(action.action == .ignore); + } + + // Matches unbound key with modifiers (falls back to catch_all without mods) + { + const action = s.getEvent(.{ + .key = .key_a, + .mods = .{ .ctrl = true }, + }).?.value_ptr.*.leaf; + try testing.expect(action.action == .ignore); + } + + // Specific binding takes precedence over catch_all + try s.parseAndPut(alloc, "ctrl+b=new_window"); + { + const action = s.getEvent(.{ + .key = .key_b, + .mods = .{ .ctrl = true }, + .unshifted_codepoint = 'b', + }).?.value_ptr.*.leaf; + try testing.expect(action.action == .new_window); + } +} + +test "set: getEvent catch_all with modifiers" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "ctrl+catch_all=close_surface"); + try s.parseAndPut(alloc, "catch_all=ignore"); + + // Key with ctrl matches catch_all with ctrl modifier + { + const action = s.getEvent(.{ + .key = .key_a, + .mods = .{ .ctrl = true }, + }).?.value_ptr.*.leaf; + try testing.expect(action.action == .close_surface); + } + + // Key without mods matches catch_all without mods + { + const action = s.getEvent(.{ + .key = .key_a, + .mods = .{}, + }).?.value_ptr.*.leaf; + try testing.expect(action.action == .ignore); + } + + // Key with different mods falls back to catch_all without mods + { + const action = s.getEvent(.{ + .key = .key_a, + .mods = .{ .alt = true }, + }).?.value_ptr.*.leaf; + try testing.expect(action.action == .ignore); + } +} + test "Action: clone" { const testing = std.testing; var arena = std.heap.ArenaAllocator.init(testing.allocator);