mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-01-08 22:33:20 +00:00
Chained Keybinds (#10015)
Fixes #9961 This implements chained keybinds as described in #9961. ``` keybind = ctrl+shift+f=toggle_fullscreen keybind = chain=toggle_window_decorations ``` These work with tables and sequences. For tables, the chain is unique per table, so the following works: ``` keybind = foo/ctrl+shift+f=toggle_fullscreen keybind = foo/chain=toggle_window_decorations ``` For sequences, it applies to the most recent sequence: ``` keybind = ctrl+b>f=toggle_fullscreen keybind = chain=toggle_window_decorations ``` ## TODO Some limitations to resolve in future PRs (make an issue) or commits: - [x] GTK: Global shortcuts cannot be chained: #10019 - [x] Inspector doesn't show chained keybinds - [x] `+list-keybinds` doesn't show chains **AI disclosure:** AI helped write tests, but everything else was organic. AI did surprisingly bad at trying to implement this feature, so I threw all of its work away! 😄
This commit is contained in:
53
src/App.zig
53
src/App.zig
@@ -357,15 +357,17 @@ pub fn keyEvent(
|
||||
// Get the keybind entry for this event. We don't support key sequences
|
||||
// so we can look directly in the top-level set.
|
||||
const entry = rt_app.config.keybind.set.getEvent(event) orelse return false;
|
||||
const leaf: input.Binding.Set.Leaf = switch (entry.value_ptr.*) {
|
||||
const leaf: input.Binding.Set.GenericLeaf = switch (entry.value_ptr.*) {
|
||||
// Sequences aren't supported. Our configuration parser verifies
|
||||
// this for global keybinds but we may still get an entry for
|
||||
// a non-global keybind.
|
||||
.leader => return false,
|
||||
|
||||
// Leaf entries are good
|
||||
.leaf => |leaf| leaf,
|
||||
inline .leaf, .leaf_chained => |leaf| leaf.generic(),
|
||||
};
|
||||
const actions: []const input.Binding.Action = leaf.actionsSlice();
|
||||
assert(actions.len > 0);
|
||||
|
||||
// If we aren't focused, then we only process global keybinds.
|
||||
if (!self.focused and !leaf.flags.global) return false;
|
||||
@@ -373,13 +375,7 @@ pub fn keyEvent(
|
||||
// Global keybinds are done using performAll so that they
|
||||
// can target all surfaces too.
|
||||
if (leaf.flags.global) {
|
||||
self.performAllAction(rt_app, leaf.action) catch |err| {
|
||||
log.warn("error performing global keybind action action={s} err={}", .{
|
||||
@tagName(leaf.action),
|
||||
err,
|
||||
});
|
||||
};
|
||||
|
||||
self.performAllChainedAction(rt_app, actions);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -389,14 +385,20 @@ pub fn keyEvent(
|
||||
|
||||
// If we are focused, then we process keybinds only if they are
|
||||
// app-scoped. Otherwise, we do nothing. Surface-scoped should
|
||||
// be processed by Surface.keyEvent.
|
||||
const app_action = leaf.action.scoped(.app) orelse return false;
|
||||
self.performAction(rt_app, app_action) catch |err| {
|
||||
log.warn("error performing app keybind action action={s} err={}", .{
|
||||
@tagName(app_action),
|
||||
err,
|
||||
});
|
||||
};
|
||||
// be processed by Surface.keyEvent. For chained actions, all
|
||||
// actions must be app-scoped.
|
||||
for (actions) |action| if (action.scoped(.app) == null) return false;
|
||||
for (actions) |action| {
|
||||
self.performAction(
|
||||
rt_app,
|
||||
action.scoped(.app).?,
|
||||
) catch |err| {
|
||||
log.warn("error performing app keybind action action={s} err={}", .{
|
||||
@tagName(action),
|
||||
err,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -454,6 +456,23 @@ pub fn performAction(
|
||||
}
|
||||
}
|
||||
|
||||
/// Performs a chained action. We will continue executing each action
|
||||
/// even if there is a failure in a prior action.
|
||||
pub fn performAllChainedAction(
|
||||
self: *App,
|
||||
rt_app: *apprt.App,
|
||||
actions: []const input.Binding.Action,
|
||||
) void {
|
||||
for (actions) |action| {
|
||||
self.performAllAction(rt_app, action) catch |err| {
|
||||
log.warn("error performing chained action action={s} err={}", .{
|
||||
@tagName(action),
|
||||
err,
|
||||
});
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Perform an app-wide binding action. If the action is surface-specific
|
||||
/// then it will be performed on all surfaces. To perform only app-scoped
|
||||
/// actions, use performAction.
|
||||
|
||||
@@ -2866,7 +2866,7 @@ fn maybeHandleBinding(
|
||||
};
|
||||
|
||||
// Determine if this entry has an action or if its a leader key.
|
||||
const leaf: input.Binding.Set.Leaf = switch (entry.value_ptr.*) {
|
||||
const leaf: input.Binding.Set.GenericLeaf = switch (entry.value_ptr.*) {
|
||||
.leader => |set| {
|
||||
// Setup the next set we'll look at.
|
||||
self.keyboard.sequence_set = set;
|
||||
@@ -2893,9 +2893,8 @@ fn maybeHandleBinding(
|
||||
return .consumed;
|
||||
},
|
||||
|
||||
.leaf => |leaf| leaf,
|
||||
inline .leaf, .leaf_chained => |leaf| leaf.generic(),
|
||||
};
|
||||
const action = leaf.action;
|
||||
|
||||
// consumed determines if the input is consumed or if we continue
|
||||
// encoding the key (if we have a key to encode).
|
||||
@@ -2917,36 +2916,58 @@ fn maybeHandleBinding(
|
||||
// An action also always resets the sequence set.
|
||||
self.keyboard.sequence_set = null;
|
||||
|
||||
// Setup our actions
|
||||
const actions = leaf.actionsSlice();
|
||||
|
||||
// Attempt to perform the action
|
||||
log.debug("key event binding flags={} action={f}", .{
|
||||
log.debug("key event binding flags={} action={any}", .{
|
||||
leaf.flags,
|
||||
action,
|
||||
actions,
|
||||
});
|
||||
const performed = performed: {
|
||||
// If this is a global or all action, then we perform it on
|
||||
// the app and it applies to every surface.
|
||||
if (leaf.flags.global or leaf.flags.all) {
|
||||
try self.app.performAllAction(self.rt_app, action);
|
||||
self.app.performAllChainedAction(
|
||||
self.rt_app,
|
||||
actions,
|
||||
);
|
||||
|
||||
// "All" actions are always performed since they are global.
|
||||
break :performed true;
|
||||
}
|
||||
|
||||
break :performed try self.performBindingAction(action);
|
||||
// Perform each action. We are performed if ANY of the chained
|
||||
// actions perform.
|
||||
var performed: bool = false;
|
||||
for (actions) |action| {
|
||||
if (self.performBindingAction(action)) |v| {
|
||||
performed = performed or v;
|
||||
} else |err| {
|
||||
log.info(
|
||||
"key binding action failed action={t} err={}",
|
||||
.{ action, err },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
break :performed performed;
|
||||
};
|
||||
|
||||
if (performed) {
|
||||
// If we performed an action and it was a closing action,
|
||||
// our "self" pointer is not safe to use anymore so we need to
|
||||
// just exit immediately.
|
||||
if (closingAction(action)) {
|
||||
for (actions) |action| if (closingAction(action)) {
|
||||
log.debug("key binding is a closing binding, halting key event processing", .{});
|
||||
return .closed;
|
||||
}
|
||||
};
|
||||
|
||||
// If our action was "ignore" then we return the special input
|
||||
// effect of "ignored".
|
||||
if (action == .ignore) return .ignored;
|
||||
for (actions) |action| if (action == .ignore) {
|
||||
return .ignored;
|
||||
};
|
||||
}
|
||||
|
||||
// If we have the performable flag and the action was not performed,
|
||||
@@ -2970,7 +2991,18 @@ fn maybeHandleBinding(
|
||||
// Store our last trigger so we don't encode the release event
|
||||
self.keyboard.last_trigger = event.bindingHash();
|
||||
|
||||
if (insp_ev) |ev| ev.binding = action;
|
||||
if (insp_ev) |ev| {
|
||||
ev.binding = self.alloc.dupe(
|
||||
input.Binding.Action,
|
||||
actions,
|
||||
) catch |err| binding: {
|
||||
log.warn(
|
||||
"error allocating binding action for inspector err={}",
|
||||
.{err},
|
||||
);
|
||||
break :binding &.{};
|
||||
};
|
||||
}
|
||||
return .consumed;
|
||||
}
|
||||
|
||||
|
||||
@@ -155,7 +155,7 @@ pub const App = struct {
|
||||
while (it.next()) |entry| {
|
||||
switch (entry.value_ptr.*) {
|
||||
.leader => {},
|
||||
.leaf => |leaf| if (leaf.flags.global) return true,
|
||||
inline .leaf, .leaf_chained => |leaf| if (leaf.flags.global) return true,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -169,13 +169,17 @@ pub const GlobalShortcuts = extern struct {
|
||||
var trigger_buf: [1024]u8 = undefined;
|
||||
var it = config.keybind.set.bindings.iterator();
|
||||
while (it.next()) |entry| {
|
||||
const leaf = switch (entry.value_ptr.*) {
|
||||
// Global shortcuts can't have leaders
|
||||
const leaf: Binding.Set.GenericLeaf = switch (entry.value_ptr.*) {
|
||||
.leader => continue,
|
||||
.leaf => |leaf| leaf,
|
||||
inline .leaf, .leaf_chained => |leaf| leaf.generic(),
|
||||
};
|
||||
if (!leaf.flags.global) continue;
|
||||
|
||||
// We only allow global keybinds that map to exactly a single
|
||||
// action for now. TODO: remove this restriction
|
||||
const actions = leaf.actionsSlice();
|
||||
if (actions.len != 1) continue;
|
||||
|
||||
const trigger = if (key.xdgShortcutFromTrigger(
|
||||
&trigger_buf,
|
||||
entry.key_ptr.*,
|
||||
@@ -197,7 +201,7 @@ pub const GlobalShortcuts = extern struct {
|
||||
try priv.map.put(
|
||||
alloc,
|
||||
try alloc.dupeZ(u8, trigger),
|
||||
leaf.action,
|
||||
actions[0],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -96,7 +96,7 @@ const TriggerNode = struct {
|
||||
|
||||
const ChordBinding = struct {
|
||||
triggers: std.SinglyLinkedList,
|
||||
action: Binding.Action,
|
||||
actions: []const Binding.Action,
|
||||
|
||||
// Order keybinds based on various properties
|
||||
// 1. Longest chord sequence
|
||||
@@ -281,16 +281,32 @@ fn prettyPrint(alloc: Allocator, keybinds: Config.Keybinds) !u8 {
|
||||
}
|
||||
}
|
||||
|
||||
const action = try std.fmt.allocPrint(alloc, "{f}", .{bind.action});
|
||||
// If our action has an argument, we print the argument in a different color
|
||||
if (std.mem.indexOfScalar(u8, action, ':')) |idx| {
|
||||
_ = win.print(&.{
|
||||
.{ .text = action[0..idx] },
|
||||
.{ .text = action[idx .. idx + 1], .style = .{ .dim = true } },
|
||||
.{ .text = action[idx + 1 ..], .style = .{ .fg = .{ .index = 5 } } },
|
||||
}, .{ .col_offset = widest_chord + 3 });
|
||||
} else {
|
||||
_ = win.printSegment(.{ .text = action }, .{ .col_offset = widest_chord + 3 });
|
||||
var action_col: u16 = widest_chord + 3;
|
||||
for (bind.actions, 0..) |act, i| {
|
||||
if (i > 0) {
|
||||
const chain_result = win.printSegment(
|
||||
.{ .text = ", ", .style = .{ .dim = true } },
|
||||
.{ .col_offset = action_col },
|
||||
);
|
||||
action_col = chain_result.col;
|
||||
}
|
||||
|
||||
const action = try std.fmt.allocPrint(alloc, "{f}", .{act});
|
||||
// If our action has an argument, we print the argument in a different color
|
||||
if (std.mem.indexOfScalar(u8, action, ':')) |idx| {
|
||||
const print_result = win.print(&.{
|
||||
.{ .text = action[0..idx] },
|
||||
.{ .text = action[idx .. idx + 1], .style = .{ .dim = true } },
|
||||
.{ .text = action[idx + 1 ..], .style = .{ .fg = .{ .index = 5 } } },
|
||||
}, .{ .col_offset = action_col });
|
||||
action_col = print_result.col;
|
||||
} else {
|
||||
const print_result = win.printSegment(
|
||||
.{ .text = action },
|
||||
.{ .col_offset = action_col },
|
||||
);
|
||||
action_col = print_result.col;
|
||||
}
|
||||
}
|
||||
try vx.prettyPrint(writer);
|
||||
}
|
||||
@@ -326,7 +342,6 @@ fn iterateBindings(
|
||||
|
||||
switch (bind.value_ptr.*) {
|
||||
.leader => |leader| {
|
||||
|
||||
// Recursively iterate on the set of bindings for this leader key
|
||||
var n_iter = leader.bindings.iterator();
|
||||
const sub_bindings, const max_width = try iterateBindings(alloc, &n_iter, win);
|
||||
@@ -347,10 +362,23 @@ fn iterateBindings(
|
||||
const node = try alloc.create(TriggerNode);
|
||||
node.* = .{ .data = bind.key_ptr.* };
|
||||
|
||||
const actions = try alloc.alloc(Binding.Action, 1);
|
||||
actions[0] = leaf.action;
|
||||
|
||||
widest_chord = @max(widest_chord, width);
|
||||
try bindings.append(alloc, .{
|
||||
.triggers = .{ .first = &node.node },
|
||||
.action = leaf.action,
|
||||
.actions = actions,
|
||||
});
|
||||
},
|
||||
.leaf_chained => |leaf| {
|
||||
const node = try alloc.create(TriggerNode);
|
||||
node.* = .{ .data = bind.key_ptr.* };
|
||||
|
||||
widest_chord = @max(widest_chord, width);
|
||||
try bindings.append(alloc, .{
|
||||
.triggers = .{ .first = &node.node },
|
||||
.actions = leaf.actions.items,
|
||||
});
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1667,6 +1667,44 @@ class: ?[:0]const u8 = null,
|
||||
/// - Notably, global shortcuts have not been implemented on wlroots-based
|
||||
/// compositors like Sway (see [upstream issue](https://github.com/emersion/xdg-desktop-portal-wlr/issues/240)).
|
||||
///
|
||||
/// ## Chained Actions
|
||||
///
|
||||
/// A keybind can have multiple actions by using the `chain` keyword for
|
||||
/// subsequent actions. When a keybind is activated, all chained actions are
|
||||
/// executed in order. The syntax is:
|
||||
///
|
||||
/// ```ini
|
||||
/// keybind = ctrl+a=new_window
|
||||
/// keybind = chain=goto_split:left
|
||||
/// ```
|
||||
///
|
||||
/// This binds `ctrl+a` to first open a new window, then move focus to the
|
||||
/// left split. Each `chain` entry appends an action to the most recently
|
||||
/// defined keybind. You can chain as many actions as you want:
|
||||
///
|
||||
/// ```ini
|
||||
/// keybind = ctrl+a=new_window
|
||||
/// keybind = chain=goto_split:left
|
||||
/// keybind = chain=toggle_fullscreen
|
||||
/// ```
|
||||
///
|
||||
/// Chained actions cannot have prefixes like `global:` or `unconsumed:`.
|
||||
/// The flags from the original keybind apply to the entire chain.
|
||||
///
|
||||
/// Chained actions work with key sequences as well. For example:
|
||||
///
|
||||
/// ```ini
|
||||
/// keybind = ctrl+a>n=new_window
|
||||
/// keybind = chain=goto_split:left
|
||||
/// ````
|
||||
///
|
||||
/// Chains with key sequences apply to the most recent binding in the
|
||||
/// sequence.
|
||||
///
|
||||
/// Chained keybinds are available since Ghostty 1.3.0.
|
||||
///
|
||||
/// ## Key Tables
|
||||
///
|
||||
/// You may also create a named set of keybindings known as a "key table."
|
||||
/// A key table must be explicitly activated for the bindings to become
|
||||
/// available. This can be used to implement features such as a
|
||||
@@ -6749,6 +6787,21 @@ pub const Keybinds = struct {
|
||||
other_leaf,
|
||||
)) return false;
|
||||
},
|
||||
|
||||
.leaf_chained => {
|
||||
const self_chain = self_entry.value_ptr.*.leaf_chained;
|
||||
const other_chain = other_entry.value_ptr.*.leaf_chained;
|
||||
|
||||
if (self_chain.flags != other_chain.flags) return false;
|
||||
if (self_chain.actions.items.len != other_chain.actions.items.len) return false;
|
||||
for (self_chain.actions.items, other_chain.actions.items) |a1, a2| {
|
||||
if (!equalField(
|
||||
inputpkg.Binding.Action,
|
||||
a1,
|
||||
a2,
|
||||
)) return false;
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,7 +13,8 @@ pub const Event = struct {
|
||||
event: input.KeyEvent,
|
||||
|
||||
/// The binding that was triggered as a result of this event.
|
||||
binding: ?input.Binding.Action = null,
|
||||
/// Multiple bindings are possible if they are chained.
|
||||
binding: []const input.Binding.Action = &.{},
|
||||
|
||||
/// The data sent to the pty as a result of this keyboard event.
|
||||
/// This is allocated using the inspector allocator.
|
||||
@@ -32,6 +33,7 @@ pub const Event = struct {
|
||||
}
|
||||
|
||||
pub fn deinit(self: *const Event, alloc: Allocator) void {
|
||||
alloc.free(self.binding);
|
||||
if (self.event.utf8.len > 0) alloc.free(self.event.utf8);
|
||||
if (self.pty.len > 0) alloc.free(self.pty);
|
||||
}
|
||||
@@ -79,12 +81,28 @@ pub const Event = struct {
|
||||
);
|
||||
defer cimgui.c.igEndTable();
|
||||
|
||||
if (self.binding) |binding| {
|
||||
if (self.binding.len > 0) {
|
||||
cimgui.c.igTableNextRow(cimgui.c.ImGuiTableRowFlags_None, 0);
|
||||
_ = cimgui.c.igTableSetColumnIndex(0);
|
||||
cimgui.c.igText("Triggered Binding");
|
||||
_ = cimgui.c.igTableSetColumnIndex(1);
|
||||
cimgui.c.igText("%s", @tagName(binding).ptr);
|
||||
|
||||
const height: f32 = height: {
|
||||
const item_count: f32 = @floatFromInt(@min(self.binding.len, 5));
|
||||
const padding = cimgui.c.igGetStyle().*.FramePadding.y * 2;
|
||||
break :height cimgui.c.igGetTextLineHeightWithSpacing() * item_count + padding;
|
||||
};
|
||||
if (cimgui.c.igBeginListBox("##bindings", .{ .x = 0, .y = height })) {
|
||||
defer cimgui.c.igEndListBox();
|
||||
for (self.binding) |action| {
|
||||
_ = cimgui.c.igSelectable_Bool(
|
||||
@tagName(action).ptr,
|
||||
false,
|
||||
cimgui.c.ImGuiSelectableFlags_None,
|
||||
.{ .x = 0, .y = 0 },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pty: {
|
||||
|
||||
Reference in New Issue
Block a user