Skip to content

Commit

Permalink
performable: prefix (#4345)
Browse files Browse the repository at this point in the history
closes #4328
closes #3970

makes this possible now
```
keybind = performable:ctrl+c=copy_to_clipboard # copy if theres a selection else send sigint
keybind = ctrl+v=paste_from_clipboard
```
  • Loading branch information
mitchellh authored Jan 3, 2025
2 parents bcd4b3a + e6399c9 commit 7e1b7bb
Show file tree
Hide file tree
Showing 3 changed files with 65 additions and 14 deletions.
25 changes: 20 additions & 5 deletions src/Surface.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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| {
Expand Down Expand Up @@ -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(
Expand Down
36 changes: 27 additions & 9 deletions src/config/Config.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
18 changes: 18 additions & 0 deletions src/input/Binding.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"));

Expand Down

0 comments on commit 7e1b7bb

Please sign in to comment.