mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-04-18 13:30:29 +00:00
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:
183
src/Surface.zig
183
src/Surface.zig
@@ -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"),
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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 &.{},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user