mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-12-28 17:14:39 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -74,6 +74,8 @@ fn writeTriggerKey(
|
||||
try writer.print("{u}", .{cp});
|
||||
}
|
||||
},
|
||||
|
||||
.catch_all => return false,
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user