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

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