Key Tables (#9984)

This does the core implementation of #9963. This implements the config
parsing, bindings (`activate_key_table`, `activate_key_table_once`,
`deactivate_key_table` and `deactivate_all_key_tables`), and core key
handling logic so they work.

I'm not going to close the issue yet because I still want to integrate
GUI onto it so that it's clear you're in a key table (similar to the
sequence UI).

No demos or anything here because it is well explained in #9963.
This commit is contained in:
Mitchell Hashimoto
2025-12-20 19:43:56 -08:00
committed by GitHub
4 changed files with 536 additions and 51 deletions

View File

@@ -253,18 +253,9 @@ const Mouse = struct {
/// Keyboard state for the surface.
pub const Keyboard = struct {
/// The currently active keybindings for the surface. This is used to
/// implement sequences: as leader keys are pressed, the active bindings
/// set is updated to reflect the current leader key sequence. If this is
/// null then the root bindings are used.
bindings: ?*const input.Binding.Set = null,
/// The last handled binding. This is used to prevent encoding release
/// events for handled bindings. We only need to keep track of one because
/// at least at the time of writing this, its impossible for two keys of
/// a combination to be handled by different bindings before the release
/// of the prior (namely since you can't bind modifier-only).
last_trigger: ?u64 = null,
/// The currently active key sequence for the surface. If this is null
/// then we're not currently in a key sequence.
sequence_set: ?*const input.Binding.Set = null,
/// The queued keys when we're in the middle of a sequenced binding.
/// These are flushed when the sequence is completed and unconsumed or
@@ -272,7 +263,21 @@ pub const Keyboard = struct {
///
/// This is naturally bounded due to the configuration maximum
/// length of a sequence.
queued: std.ArrayListUnmanaged(termio.Message.WriteReq) = .{},
sequence_queued: std.ArrayListUnmanaged(termio.Message.WriteReq) = .empty,
/// The stack of tables that is currently active. The first value
/// in this is the first activated table (NOT the default keybinding set).
table_stack: std.ArrayListUnmanaged(struct {
set: *const input.Binding.Set,
once: bool,
}) = .empty,
/// The last handled binding. This is used to prevent encoding release
/// events for handled bindings. We only need to keep track of one because
/// at least at the time of writing this, its impossible for two keys of
/// a combination to be handled by different bindings before the release
/// of the prior (namely since you can't bind modifier-only).
last_trigger: ?u64 = null,
};
/// The configuration that a surface has, this is copied from the main
@@ -793,8 +798,9 @@ pub fn deinit(self: *Surface) void {
}
// Clean up our keyboard state
for (self.keyboard.queued.items) |req| req.deinit();
self.keyboard.queued.deinit(self.alloc);
for (self.keyboard.sequence_queued.items) |req| req.deinit();
self.keyboard.sequence_queued.deinit(self.alloc);
self.keyboard.table_stack.deinit(self.alloc);
// Clean up our font grid
self.app.font_grid_set.deref(self.font_grid_key);
@@ -2563,14 +2569,22 @@ pub fn keyEventIsBinding(
.press, .repeat => {},
}
// Our keybinding set is either our current nested set (for
// sequences) or the root set.
const set = self.keyboard.bindings orelse &self.config.keybind.set;
// If we're in a sequence, check the sequence set
if (self.keyboard.sequence_set) |set| {
return set.getEvent(event) != null;
}
// log.warn("text keyEventIsBinding event={} match={}", .{ event, set.getEvent(event) != null });
// Check active key tables (inner-most to outer-most)
const table_items = self.keyboard.table_stack.items;
for (0..table_items.len) |i| {
const rev_i: usize = table_items.len - 1 - i;
if (table_items[rev_i].set.getEvent(event) != null) {
return true;
}
}
// If we have a keybinding for this event then we return true.
return set.getEvent(event) != null;
// Check the root set
return self.config.keybind.set.getEvent(event) != null;
}
/// Called for any key events. This handles keybindings, encoding and
@@ -2791,38 +2805,63 @@ fn maybeHandleBinding(
// Find an entry in the keybind set that matches our event.
const entry: input.Binding.Set.Entry = entry: {
const set = self.keyboard.bindings orelse &self.config.keybind.set;
// Handle key sequences first.
if (self.keyboard.sequence_set) |set| {
// Get our entry from the set for the given event.
if (set.getEvent(event)) |v| break :entry v;
// Get our entry from the set for the given event.
if (set.getEvent(event)) |v| break :entry v;
// No entry found. We need to encode everything up to this
// point and send to the pty since we're in a sequence.
//
// We also ignore modifiers so that nested sequences such as
// ctrl+a>ctrl+b>c work.
if (!event.key.modifier()) {
// Encode everything up to this point
self.endKeySequence(.flush, .retain);
}
// No entry found. If we're not looking at the root set of the
// bindings we need to encode everything up to this point and
// send to the pty.
//
// We also ignore modifiers so that nested sequences such as
// ctrl+a>ctrl+b>c work.
if (self.keyboard.bindings != null and
!event.key.modifier())
{
// Encode everything up to this point
self.endKeySequence(.flush, .retain);
return null;
}
return null;
// No currently active sequence, move on to tables. For tables,
// we search inner-most table to outer-most. The table stack does
// NOT include the root set.
const table_items = self.keyboard.table_stack.items;
if (table_items.len > 0) {
for (0..table_items.len) |i| {
const rev_i: usize = table_items.len - 1 - i;
const table = table_items[rev_i];
if (table.set.getEvent(event)) |v| {
// If this is a one-shot activation AND its the currently
// active table, then we deactivate it after this.
// Note: we may want to change the semantics here to
// remove this table no matter where it is in the stack,
// maybe.
if (table.once and i == 0) _ = try self.performBindingAction(
.deactivate_key_table,
);
break :entry v;
}
}
}
// No table, use our default set
break :entry self.config.keybind.set.getEvent(event) orelse
return null;
};
// Determine if this entry has an action or if its a leader key.
const leaf: input.Binding.Set.Leaf = switch (entry.value_ptr.*) {
.leader => |set| {
// Setup the next set we'll look at.
self.keyboard.bindings = set;
self.keyboard.sequence_set = set;
// Store this event so that we can drain and encode on invalid.
// We don't need to cap this because it is naturally capped by
// the config validation.
if (try self.encodeKey(event, insp_ev)) |req| {
try self.keyboard.queued.append(self.alloc, req);
try self.keyboard.sequence_queued.append(self.alloc, req);
}
// Start or continue our key sequence
@@ -2861,8 +2900,8 @@ fn maybeHandleBinding(
// perform an action (below)
self.keyboard.last_trigger = null;
// An action also always resets the binding set.
self.keyboard.bindings = null;
// An action also always resets the sequence set.
self.keyboard.sequence_set = null;
// Attempt to perform the action
log.debug("key event binding flags={} action={f}", .{
@@ -2952,13 +2991,13 @@ fn endKeySequence(
);
};
// No matter what we clear our current binding set. This restores
// No matter what we clear our current sequence set. This restores
// the set we look at to the root set.
self.keyboard.bindings = null;
self.keyboard.sequence_set = null;
if (self.keyboard.queued.items.len > 0) {
if (self.keyboard.sequence_queued.items.len > 0) {
switch (action) {
.flush => for (self.keyboard.queued.items) |write_req| {
.flush => for (self.keyboard.sequence_queued.items) |write_req| {
self.queueIo(switch (write_req) {
.small => |v| .{ .write_small = v },
.stable => |v| .{ .write_stable = v },
@@ -2966,12 +3005,12 @@ fn endKeySequence(
}, .unlocked);
},
.drop => for (self.keyboard.queued.items) |req| req.deinit(),
.drop => for (self.keyboard.sequence_queued.items) |req| req.deinit(),
}
switch (mem) {
.free => self.keyboard.queued.clearAndFree(self.alloc),
.retain => self.keyboard.queued.clearRetainingCapacity(),
.free => self.keyboard.sequence_queued.clearAndFree(self.alloc),
.retain => self.keyboard.sequence_queued.clearRetainingCapacity(),
}
}
}
@@ -5566,6 +5605,56 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
{},
),
inline .activate_key_table,
.activate_key_table_once,
=> |name, tag| {
// Look up the table in our config
const set = self.config.keybind.tables.getPtr(name) orelse {
log.debug("key table not found: {s}", .{name});
return false;
};
// If this is the same table as is currently active, then
// do nothing.
if (self.keyboard.table_stack.items.len > 0) {
const items = self.keyboard.table_stack.items;
const active = items[items.len - 1].set;
if (active == set) {
log.debug("ignoring duplicate activate table: {s}", .{name});
return false;
}
}
// Add the table to the stack.
try self.keyboard.table_stack.append(self.alloc, .{
.set = set,
.once = tag == .activate_key_table_once,
});
log.debug("key table activated: {s}", .{name});
},
.deactivate_key_table => switch (self.keyboard.table_stack.items.len) {
// No key table active. This does nothing.
0 => return false,
// Final key table active, clear our state.
1 => self.keyboard.table_stack.clearAndFree(self.alloc),
// Restore the prior key table. We don't free any memory in
// this case because we assume it will be freed later when
// we finish our key table.
else => _ = self.keyboard.table_stack.pop(),
},
.deactivate_all_key_tables => switch (self.keyboard.table_stack.items.len) {
// No key table active. This does nothing.
0 => return false,
// Clear the entire table stack.
else => self.keyboard.table_stack.clearAndFree(self.alloc),
},
.crash => |location| switch (location) {
.main => @panic("crash binding action, crashing intentionally"),

View File

@@ -5812,12 +5812,17 @@ pub const RepeatableFontVariation = struct {
pub const Keybinds = struct {
set: inputpkg.Binding.Set = .{},
/// Defined key tables. The default key table is always the root "set",
/// which allows all table names to be available without reservation.
tables: std.StringArrayHashMapUnmanaged(inputpkg.Binding.Set) = .empty,
pub fn init(self: *Keybinds, alloc: Allocator) !void {
// We don't clear the memory because it's in the arena and unlikely
// to be free-able anyways (since arenas can only clear the last
// allocated value). This isn't a memory leak because the arena
// will be freed when the config is freed.
self.set = .{};
self.tables = .empty;
// keybinds for opening and reloading config
try self.set.put(
@@ -6587,21 +6592,77 @@ pub const Keybinds = struct {
// will be freed when the config is freed.
log.info("config has 'keybind = clear', all keybinds cleared", .{});
self.set = .{};
self.tables = .empty;
return;
}
// Let our much better tested binding package handle parsing and storage.
// Check for table syntax: "name/" or "name/binding"
// We look for '/' only before the first '=' to avoid matching
// action arguments like "foo=text:/hello".
const eq_idx = std.mem.indexOfScalar(u8, value, '=') orelse value.len;
if (std.mem.indexOfScalar(u8, value[0..eq_idx], '/')) |slash_idx| {
const table_name = value[0..slash_idx];
const binding = value[slash_idx + 1 ..];
// Table name cannot be empty
if (table_name.len == 0) return error.InvalidFormat;
// Get or create the table
const gop = try self.tables.getOrPut(alloc, table_name);
if (!gop.found_existing) {
// We need to copy our table name into the arena
// for valid lookups later.
gop.key_ptr.* = try alloc.dupe(u8, table_name);
gop.value_ptr.* = .{};
}
// If there's no binding after the slash, this is a table
// definition/clear command
if (binding.len == 0) {
log.debug("config has 'keybind = {s}/', table cleared", .{table_name});
gop.value_ptr.* = .{};
return;
}
// Parse and add the binding to the table
try gop.value_ptr.parseAndPut(alloc, binding);
return;
}
// Parse into default set
try self.set.parseAndPut(alloc, value);
}
/// Deep copy of the struct. Required by Config.
pub fn clone(self: *const Keybinds, alloc: Allocator) Allocator.Error!Keybinds {
return .{ .set = try self.set.clone(alloc) };
var tables: std.StringArrayHashMapUnmanaged(inputpkg.Binding.Set) = .empty;
try tables.ensureTotalCapacity(alloc, @intCast(self.tables.count()));
var it = self.tables.iterator();
while (it.next()) |entry| {
const key = try alloc.dupe(u8, entry.key_ptr.*);
tables.putAssumeCapacity(key, try entry.value_ptr.clone(alloc));
}
return .{
.set = try self.set.clone(alloc),
.tables = tables,
};
}
/// Compare if two of our value are requal. Required by Config.
pub fn equal(self: Keybinds, other: Keybinds) bool {
return equalSet(&self.set, &other.set);
if (!equalSet(&self.set, &other.set)) return false;
// Compare tables
if (self.tables.count() != other.tables.count()) return false;
var it = self.tables.iterator();
while (it.next()) |entry| {
const other_set = other.tables.get(entry.key_ptr.*) orelse return false;
if (!equalSet(entry.value_ptr, &other_set)) return false;
}
return true;
}
fn equalSet(
@@ -6652,12 +6713,14 @@ pub const Keybinds = struct {
/// Like formatEntry but has an option to include docs.
pub fn formatEntryDocs(self: Keybinds, formatter: formatterpkg.EntryFormatter, docs: bool) !void {
if (self.set.bindings.size == 0) {
if (self.set.bindings.size == 0 and self.tables.count() == 0) {
try formatter.formatEntry(void, {});
return;
}
var buf: [1024]u8 = undefined;
// Format root set bindings
var iter = self.set.bindings.iterator();
while (iter.next()) |next| {
const k = next.key_ptr.*;
@@ -6684,6 +6747,23 @@ pub const Keybinds = struct {
writer.print("{f}", .{k}) catch return error.OutOfMemory;
try v.formatEntries(&writer, formatter);
}
// Format table bindings
var table_iter = self.tables.iterator();
while (table_iter.next()) |table_entry| {
const table_name = table_entry.key_ptr.*;
const table_set = table_entry.value_ptr.*;
var binding_iter = table_set.bindings.iterator();
while (binding_iter.next()) |next| {
const k = next.key_ptr.*;
const v = next.value_ptr.*;
var writer: std.Io.Writer = .fixed(&buf);
writer.print("{s}/{f}", .{ table_name, k }) catch return error.OutOfMemory;
try v.formatEntries(&writer, formatter);
}
}
}
/// Used by Formatter
@@ -6768,6 +6848,283 @@ pub const Keybinds = struct {
;
try std.testing.expectEqualStrings(want, buf.written());
}
test "parseCLI table definition" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var keybinds: Keybinds = .{};
// Define a table by adding a binding to it
try keybinds.parseCLI(alloc, "foo/shift+a=copy_to_clipboard");
try testing.expectEqual(1, keybinds.tables.count());
try testing.expect(keybinds.tables.contains("foo"));
const table = keybinds.tables.get("foo").?;
try testing.expectEqual(1, table.bindings.count());
}
test "parseCLI table clear" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var keybinds: Keybinds = .{};
// Add a binding to a table
try keybinds.parseCLI(alloc, "foo/shift+a=copy_to_clipboard");
try testing.expectEqual(1, keybinds.tables.get("foo").?.bindings.count());
// Clear the table with "foo/"
try keybinds.parseCLI(alloc, "foo/");
try testing.expectEqual(0, keybinds.tables.get("foo").?.bindings.count());
}
test "parseCLI table multiple bindings" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var keybinds: Keybinds = .{};
try keybinds.parseCLI(alloc, "foo/shift+a=copy_to_clipboard");
try keybinds.parseCLI(alloc, "foo/shift+b=paste_from_clipboard");
try keybinds.parseCLI(alloc, "bar/ctrl+c=close_window");
try testing.expectEqual(2, keybinds.tables.count());
try testing.expectEqual(2, keybinds.tables.get("foo").?.bindings.count());
try testing.expectEqual(1, keybinds.tables.get("bar").?.bindings.count());
}
test "parseCLI table does not affect root set" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var keybinds: Keybinds = .{};
try keybinds.parseCLI(alloc, "shift+a=copy_to_clipboard");
try keybinds.parseCLI(alloc, "foo/shift+b=paste_from_clipboard");
// Root set should have the first binding
try testing.expectEqual(1, keybinds.set.bindings.count());
// Table should have the second binding
try testing.expectEqual(1, keybinds.tables.get("foo").?.bindings.count());
}
test "parseCLI table empty name is invalid" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var keybinds: Keybinds = .{};
try testing.expectError(error.InvalidFormat, keybinds.parseCLI(alloc, "/shift+a=copy_to_clipboard"));
}
test "parseCLI table with key sequence" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var keybinds: Keybinds = .{};
// Key sequences should work within tables
try keybinds.parseCLI(alloc, "foo/ctrl+a>ctrl+b=new_window");
const table = keybinds.tables.get("foo").?;
try testing.expectEqual(1, table.bindings.count());
}
test "parseCLI slash in action argument is not a table" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var keybinds: Keybinds = .{};
// A slash after the = should not be interpreted as a table delimiter
try keybinds.parseCLI(alloc, "ctrl+a=text:/hello");
// Should be in root set, not a table
try testing.expectEqual(1, keybinds.set.bindings.count());
try testing.expectEqual(0, keybinds.tables.count());
}
test "clone with tables" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var keybinds: Keybinds = .{};
try keybinds.parseCLI(alloc, "shift+a=copy_to_clipboard");
try keybinds.parseCLI(alloc, "foo/shift+b=paste_from_clipboard");
try keybinds.parseCLI(alloc, "bar/ctrl+c=close_window");
const cloned = try keybinds.clone(alloc);
// Verify the clone has the same structure
try testing.expectEqual(keybinds.set.bindings.count(), cloned.set.bindings.count());
try testing.expectEqual(keybinds.tables.count(), cloned.tables.count());
try testing.expectEqual(
keybinds.tables.get("foo").?.bindings.count(),
cloned.tables.get("foo").?.bindings.count(),
);
try testing.expectEqual(
keybinds.tables.get("bar").?.bindings.count(),
cloned.tables.get("bar").?.bindings.count(),
);
}
test "equal with tables" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var keybinds1: Keybinds = .{};
try keybinds1.parseCLI(alloc, "foo/shift+a=copy_to_clipboard");
var keybinds2: Keybinds = .{};
try keybinds2.parseCLI(alloc, "foo/shift+a=copy_to_clipboard");
try testing.expect(keybinds1.equal(keybinds2));
}
test "equal with tables different table count" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var keybinds1: Keybinds = .{};
try keybinds1.parseCLI(alloc, "foo/shift+a=copy_to_clipboard");
var keybinds2: Keybinds = .{};
try keybinds2.parseCLI(alloc, "foo/shift+a=copy_to_clipboard");
try keybinds2.parseCLI(alloc, "bar/shift+b=paste_from_clipboard");
try testing.expect(!keybinds1.equal(keybinds2));
}
test "equal with tables different table names" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var keybinds1: Keybinds = .{};
try keybinds1.parseCLI(alloc, "foo/shift+a=copy_to_clipboard");
var keybinds2: Keybinds = .{};
try keybinds2.parseCLI(alloc, "bar/shift+a=copy_to_clipboard");
try testing.expect(!keybinds1.equal(keybinds2));
}
test "equal with tables different bindings" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var keybinds1: Keybinds = .{};
try keybinds1.parseCLI(alloc, "foo/shift+a=copy_to_clipboard");
var keybinds2: Keybinds = .{};
try keybinds2.parseCLI(alloc, "foo/shift+b=paste_from_clipboard");
try testing.expect(!keybinds1.equal(keybinds2));
}
test "formatEntry with tables" {
const testing = std.testing;
var buf: std.Io.Writer.Allocating = .init(testing.allocator);
defer buf.deinit();
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var keybinds: Keybinds = .{};
try keybinds.parseCLI(alloc, "foo/shift+a=csi:hello");
try keybinds.formatEntry(formatterpkg.entryFormatter("keybind", &buf.writer));
try testing.expectEqualStrings("keybind = foo/shift+a=csi:hello\n", buf.written());
}
test "formatEntry with tables and root set" {
const testing = std.testing;
var buf: std.Io.Writer.Allocating = .init(testing.allocator);
defer buf.deinit();
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var keybinds: Keybinds = .{};
try keybinds.parseCLI(alloc, "shift+b=csi:world");
try keybinds.parseCLI(alloc, "foo/shift+a=csi:hello");
try keybinds.formatEntry(formatterpkg.entryFormatter("keybind", &buf.writer));
const output = buf.written();
try testing.expect(std.mem.indexOf(u8, output, "keybind = shift+b=csi:world\n") != null);
try testing.expect(std.mem.indexOf(u8, output, "keybind = foo/shift+a=csi:hello\n") != null);
}
test "parseCLI clear clears tables" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var keybinds: Keybinds = .{};
// Add bindings to root set and tables
try keybinds.parseCLI(alloc, "shift+a=copy_to_clipboard");
try keybinds.parseCLI(alloc, "foo/shift+b=paste_from_clipboard");
try keybinds.parseCLI(alloc, "bar/ctrl+c=close_window");
try testing.expectEqual(1, keybinds.set.bindings.count());
try testing.expectEqual(2, keybinds.tables.count());
// Clear all keybinds
try keybinds.parseCLI(alloc, "clear");
// Both root set and tables should be cleared
try testing.expectEqual(0, keybinds.set.bindings.count());
try testing.expectEqual(0, keybinds.tables.count());
}
test "parseCLI reset clears tables" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var keybinds: Keybinds = .{};
// Add bindings to tables
try keybinds.parseCLI(alloc, "foo/shift+a=copy_to_clipboard");
try keybinds.parseCLI(alloc, "bar/shift+b=paste_from_clipboard");
try testing.expectEqual(2, keybinds.tables.count());
// Reset to defaults (empty value)
try keybinds.parseCLI(alloc, "");
// Tables should be cleared, root set has defaults
try testing.expectEqual(0, keybinds.tables.count());
try testing.expect(keybinds.set.bindings.count() > 0);
}
};
/// See "font-codepoint-map" for documentation.

View File

@@ -799,6 +799,37 @@ pub const Action = union(enum) {
/// be undone or redone.
redo,
/// Activate a named key table (see `keybind` configuration documentation).
/// The named key table will remain active until `deactivate_key_table`
/// is called. If you want a one-shot key table activation, use the
/// `activate_key_table_once` action instead.
///
/// If the named key table does not exist, this action has no effect
/// and performable will report false.
///
/// If the named key table is already the currently active key table,
/// this action has no effect and performable will report false.
activate_key_table: []const u8,
/// Same as activate_key_table, but the key table will only be active
/// until the first valid keybinding from that table is used (including
/// any defined `catch_all` bindings).
///
/// The "once" check is only done if this is the currently active
/// key table. If another key table is activated later, then this
/// table will remain active until it pops back out to being the
/// active key table.
activate_key_table_once: []const u8,
/// Deactivate the currently active key table, if any. The next most
/// recently activated key table (if any) will become active again.
/// If no key table is active, this action has no effect.
deactivate_key_table,
/// Deactivate all active key tables. If no active key table exists,
/// this will report performable as false.
deactivate_all_key_tables,
/// Quit Ghostty.
quit,
@@ -1253,6 +1284,10 @@ pub const Action = union(enum) {
.toggle_background_opacity,
.show_on_screen_keyboard,
.reset_window_size,
.activate_key_table,
.activate_key_table_once,
.deactivate_key_table,
.deactivate_all_key_tables,
.crash,
=> .surface,

View File

@@ -671,6 +671,10 @@ fn actionCommands(action: Action.Key) []const Command {
.write_scrollback_file,
.goto_tab,
.resize_split,
.activate_key_table,
.activate_key_table_once,
.deactivate_key_table,
.deactivate_all_key_tables,
.crash,
=> comptime &.{},