input: Disallow table/chain= and make chain apply to the most recent table (#10954)

Fixes #10039

(Context is all there)
This commit is contained in:
Mitchell Hashimoto
2026-02-22 14:06:38 -08:00
committed by GitHub

View File

@@ -1858,6 +1858,12 @@ class: ?[:0]const u8 = null,
/// If an invalid key is pressed, the sequence ends but the table remains
/// active.
///
/// * Chain actions work within tables, the `chain` keyword applies to
/// the most recently defined binding in the table. e.g. if you set
/// `table/ctrl+a=new_window` you can chain by using `chain=text:hello`.
/// Important: chain itself doesn't get prefixed with the table name,
/// since it applies to the most recent binding in any table.
///
/// * Prefixes like `global:` work within tables:
/// `foo/global:ctrl+a=new_window`.
///
@@ -6213,6 +6219,15 @@ pub const Keybinds = struct {
/// which allows all table names to be available without reservation.
tables: std.StringArrayHashMapUnmanaged(inputpkg.Binding.Set) = .empty,
/// The most recent binding target for `chain=` additions.
///
/// This is intentionally tracked at the Keybinds level so that chains can
/// apply across table boundaries according to parse order.
chain_target: union(enum) {
root,
table: []const u8,
} = .root,
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
@@ -6220,6 +6235,7 @@ pub const Keybinds = struct {
// will be freed when the config is freed.
self.set = .{};
self.tables = .empty;
self.chain_target = .root;
// keybinds for opening and reloading config
try self.set.put(
@@ -7002,6 +7018,7 @@ pub const Keybinds = struct {
log.info("config has 'keybind = clear', all keybinds cleared", .{});
self.set = .{};
self.tables = .empty;
self.chain_target = .root;
return;
}
@@ -7039,16 +7056,39 @@ pub const Keybinds = struct {
if (binding.len == 0) {
log.debug("config has 'keybind = {s}/', table cleared", .{table_name});
gop.value_ptr.* = .{};
self.chain_target = .root;
return;
}
// Chains are only allowed at the root level. Their target is
// tracked globally by parse order in `self.chain_target`.
if (std.mem.startsWith(u8, binding, "chain=")) {
return error.InvalidFormat;
}
// Parse and add the binding to the table
try gop.value_ptr.parseAndPut(alloc, binding);
self.chain_target = .{ .table = gop.key_ptr.* };
return;
}
if (std.mem.startsWith(u8, value, "chain=")) {
switch (self.chain_target) {
.root => try self.set.parseAndPut(alloc, value),
.table => |table_name| {
const table = self.tables.getPtr(table_name) orelse {
self.chain_target = .root;
return error.InvalidFormat;
};
try table.parseAndPut(alloc, value);
},
}
return;
}
// Parse into default set
try self.set.parseAndPut(alloc, value);
self.chain_target = .root;
}
/// Deep copy of the struct. Required by Config.
@@ -7490,6 +7530,63 @@ pub const Keybinds = struct {
try testing.expect(keybinds.tables.contains("mytable"));
}
test "parseCLI chain without prior parsed binding 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, "chain=new_tab"),
);
}
test "parseCLI table chain syntax is invalid" {
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/a=text:hello");
try testing.expectError(
error.InvalidFormat,
keybinds.parseCLI(alloc, "foo/chain=deactivate_key_table"),
);
}
test "parseCLI chain applies to most recent table binding" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var keybinds: Keybinds = .{};
try keybinds.parseCLI(alloc, "ctrl+n=activate_key_table:foo");
try keybinds.parseCLI(alloc, "foo/a=text:hello");
try keybinds.parseCLI(alloc, "chain=deactivate_key_table");
const root_entry = keybinds.set.get(.{
.mods = .{ .ctrl = true },
.key = .{ .unicode = 'n' },
}).?.value_ptr.*;
try testing.expect(root_entry == .leaf);
try testing.expect(root_entry.leaf.action == .activate_key_table);
const foo_entry = keybinds.tables.get("foo").?.get(.{
.key = .{ .unicode = 'a' },
}).?.value_ptr.*;
try testing.expect(foo_entry == .leaf_chained);
try testing.expectEqual(@as(usize, 2), foo_entry.leaf_chained.actions.items.len);
try testing.expect(foo_entry.leaf_chained.actions.items[0] == .text);
try testing.expect(foo_entry.leaf_chained.actions.items[1] == .deactivate_key_table);
}
test "clone with tables" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);