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`.
This commit is contained in:
Mitchell Hashimoto
2025-12-19 11:56:04 -08:00
parent 73a93abf7b
commit 63422f4d4e
6 changed files with 148 additions and 1 deletions

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -74,6 +74,8 @@ fn writeTriggerKey(
try writer.print("{u}", .{cp});
}
},
.catch_all => return false,
}
return true;

View File

@@ -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());

View File

@@ -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

View File

@@ -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);