diff --git a/src/Surface.zig b/src/Surface.zig index c359efd8a..389e7f7e4 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -1156,7 +1156,6 @@ pub fn updateConfig( } // If we are in the middle of a key sequence, clear it. - self.keyboard.bindings = null; self.endKeySequence(.drop, .free); // Before sending any other config changes, we give the renderer a new font @@ -1853,9 +1852,6 @@ fn maybeHandleBinding( if (self.keyboard.bindings != null and !event.key.modifier()) { - // Reset to the root set - self.keyboard.bindings = null; - // Encode everything up to this point self.endKeySequence(.flush, .retain); } @@ -1941,10 +1937,21 @@ fn maybeHandleBinding( return .closed; } + // If we have the performable flag and the action was not performed, + // then we act as though a binding didn't exist. + if (leaf.flags.performable and !performed) { + // If we're in a sequence, we treat this as if we pressed a key + // that doesn't exist in the sequence. Reset our sequence and flush + // any queued events. + self.endKeySequence(.flush, .retain); + + return null; + } + // If we consume this event, then we are done. If we don't consume // it, we processed the action but we still want to process our // encodings, too. - if (performed and consumed) { + if (consumed) { // If we had queued events, we deinit them since we consumed self.endKeySequence(.drop, .retain); @@ -1986,6 +1993,10 @@ fn endKeySequence( ); }; + // No matter what we clear our current binding set. This restores + // the set we look at to the root set. + self.keyboard.bindings = null; + if (self.keyboard.queued.items.len > 0) { switch (action) { .flush => for (self.keyboard.queued.items) |write_req| { @@ -3889,7 +3900,11 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool log.err("error setting clipboard string err={}", .{err}); return true; }; + + return true; } + + return false; }, .paste_from_clipboard => try self.startClipboardRequest( diff --git a/src/config/Config.zig b/src/config/Config.zig index 4bc6870a1..bb50f1fc6 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -929,6 +929,15 @@ class: ?[:0]const u8 = null, /// Since they are not associated with a specific terminal surface, /// they're never encoded. /// +/// * `performable:` - Only consume the input if the action is able to be +/// performed. For example, the `copy_to_clipboard` action will only +/// consume the input if there is a selection to copy. If there is no +/// selection, Ghostty behaves as if the keybind was not set. This has +/// no effect with `global:` or `all:`-prefixed keybinds. For key +/// sequences, this will reset the sequence if the action is not +/// performable (acting identically to not having a keybind set at +/// all). +/// /// Keybind triggers are not unique per prefix combination. For example, /// `ctrl+a` and `global:ctrl+a` are not two separate keybinds. The keybind /// set later will overwrite the keybind set earlier. In this case, the @@ -2221,45 +2230,53 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config { ); // Expand Selection - try result.keybind.set.put( + try result.keybind.set.putFlags( alloc, .{ .key = .{ .translated = .left }, .mods = .{ .shift = true } }, .{ .adjust_selection = .left }, + .{ .performable = true }, ); - try result.keybind.set.put( + try result.keybind.set.putFlags( alloc, .{ .key = .{ .translated = .right }, .mods = .{ .shift = true } }, .{ .adjust_selection = .right }, + .{ .performable = true }, ); - try result.keybind.set.put( + try result.keybind.set.putFlags( alloc, .{ .key = .{ .translated = .up }, .mods = .{ .shift = true } }, .{ .adjust_selection = .up }, + .{ .performable = true }, ); - try result.keybind.set.put( + try result.keybind.set.putFlags( alloc, .{ .key = .{ .translated = .down }, .mods = .{ .shift = true } }, .{ .adjust_selection = .down }, + .{ .performable = true }, ); - try result.keybind.set.put( + try result.keybind.set.putFlags( alloc, .{ .key = .{ .translated = .page_up }, .mods = .{ .shift = true } }, .{ .adjust_selection = .page_up }, + .{ .performable = true }, ); - try result.keybind.set.put( + try result.keybind.set.putFlags( alloc, .{ .key = .{ .translated = .page_down }, .mods = .{ .shift = true } }, .{ .adjust_selection = .page_down }, + .{ .performable = true }, ); - try result.keybind.set.put( + try result.keybind.set.putFlags( alloc, .{ .key = .{ .translated = .home }, .mods = .{ .shift = true } }, .{ .adjust_selection = .home }, + .{ .performable = true }, ); - try result.keybind.set.put( + try result.keybind.set.putFlags( alloc, .{ .key = .{ .translated = .end }, .mods = .{ .shift = true } }, .{ .adjust_selection = .end }, + .{ .performable = true }, ); // Tabs common to all platforms @@ -2509,10 +2526,11 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config { .{ .key = .{ .translated = .q }, .mods = .{ .super = true } }, .{ .quit = {} }, ); - try result.keybind.set.put( + try result.keybind.set.putFlags( alloc, .{ .key = .{ .translated = .k }, .mods = .{ .super = true } }, .{ .clear_screen = {} }, + .{ .performable = true }, ); try result.keybind.set.put( alloc, diff --git a/src/input/Binding.zig b/src/input/Binding.zig index bb40298d1..e588202d8 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -36,6 +36,11 @@ pub const Flags = packed struct { /// and not just while Ghostty is focused. This may not work on all platforms. /// See the keybind config documentation for more information. global: bool = false, + + /// True if this binding should only be triggered if the action can be + /// performed. If the action can't be performed then the binding acts as + /// if it doesn't exist. + performable: bool = false, }; /// Full binding parser. The binding parser is implemented as an iterator @@ -90,6 +95,9 @@ pub const Parser = struct { } else if (std.mem.eql(u8, prefix, "unconsumed")) { if (!flags.consumed) return Error.InvalidFormat; flags.consumed = false; + } else if (std.mem.eql(u8, prefix, "performable")) { + if (flags.performable) return Error.InvalidFormat; + flags.performable = true; } else { // If we don't recognize the prefix then we're done. // There are trigger-specific prefixes like "physical:" so @@ -1688,6 +1696,16 @@ test "parse: triggers" { .flags = .{ .consumed = false }, }, try parseSingle("unconsumed:physical:a+shift=ignore")); + // performable keys + try testing.expectEqual(Binding{ + .trigger = .{ + .mods = .{ .shift = true }, + .key = .{ .translated = .a }, + }, + .action = .{ .ignore = {} }, + .flags = .{ .performable = true }, + }, try parseSingle("performable:shift+a=ignore")); + // invalid key try testing.expectError(Error.InvalidFormat, parseSingle("foo=ignore"));