From bfe69b1c944f89c1497458f1c82cd7af19cc5d60 Mon Sep 17 00:00:00 2001 From: "Steven Lu (MBP M1 Max)" <1542910+unphased@users.noreply.github.com> Date: Mon, 16 Mar 2026 18:41:44 -0400 Subject: [PATCH 1/2] keybind: add else= fallback for performable bindings Add `else=` syntax that specifies a fallback action when a `performable:` binding's action cannot be performed. This allows users to configure alternative behavior instead of falling through to no-op. The `else` keyword acts as a branch switch for the `chain=` mechanism: chains before `else` append to the performed branch, chains after `else` append to the fallback branch. This enables independent action sequences for both outcomes of a performable binding. Example config: keybind = performable:super+l=resize_split:right,10 keybind = else=esc:l When splits exist, super+l resizes. Without splits, it emits esc+l. Validation rules: - else= without a preceding performable: binding is an error - Multiple else= on the same binding is an error - else= with unbind is an error Closes ghostty-org/ghostty#11355 Co-Authored-By: Claude Opus 4.6 --- src/Surface.zig | 17 +- src/config/Config.zig | 18 +- src/input/Binding.zig | 423 ++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 439 insertions(+), 19 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 4d66622e3..2e8f8ec31 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -2985,8 +2985,23 @@ fn maybeHandleBinding( } // If we have the performable flag and the action was not performed, - // then we act as though a binding didn't exist. + // execute the else branch if present, otherwise act as though a + // binding didn't exist. if (leaf.flags.performable and !performed) { + if (leaf.else_actions.len > 0) { + for (leaf.else_actions) |else_action| { + if (self.performBindingAction(else_action)) |_| {} else |err| { + log.info( + "else binding action failed action={t} err={}", + .{ else_action, err }, + ); + } + } + self.endKeySequence(.drop, .retain); + self.keyboard.last_trigger = event.bindingHash(); + return .consumed; + } + // 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. diff --git a/src/config/Config.zig b/src/config/Config.zig index 278ffd7e2..e342954f2 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -7222,9 +7222,11 @@ pub const Keybinds = struct { return; } - // Chains are only allowed at the root level. Their target is + // Chains and else 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=")) { + if (std.mem.startsWith(u8, binding, "chain=") or + std.mem.startsWith(u8, binding, "else=")) + { return error.InvalidFormat; } @@ -7234,7 +7236,9 @@ pub const Keybinds = struct { return; } - if (std.mem.startsWith(u8, value, "chain=")) { + if (std.mem.startsWith(u8, value, "chain=") or + std.mem.startsWith(u8, value, "else=")) + { switch (self.chain_target) { .root => try self.set.parseAndPut(alloc, value), .table => |table_name| { @@ -7339,6 +7343,14 @@ pub const Keybinds = struct { a2, )) return false; } + if (self_chain.else_actions.items.len != other_chain.else_actions.items.len) return false; + for (self_chain.else_actions.items, other_chain.else_actions.items) |a1, a2| { + if (!deepEqual( + inputpkg.Binding.Action, + a1, + a2, + )) return false; + } }, } } diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 62a4e39ac..c74238a75 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -76,6 +76,7 @@ pub const Parser = struct { action: Action, flags: Flags = .{}, chain: bool, + is_else: bool, pub const Elem = union(enum) { /// A leader trigger in a sequence. @@ -89,6 +90,11 @@ pub const Parser = struct { /// invalid actions for chains such as `unbind`. We expect downstream /// consumers to validate that the action is valid for chaining. chain: Action, + + /// An else action `else=` that should be used as the + /// fallback when a performable binding's action cannot be performed. + /// Like chain, any action is parsed and downstream consumers validate. + @"else": Action, }; pub fn init(raw_input: []const u8) Error!Parser { @@ -125,9 +131,10 @@ pub const Parser = struct { return Error.InvalidFormat; }; - // Detect chains. Chains must not have flags. + // Detect chains and else. Neither can have flags. const chain = std.mem.eql(u8, input[0..eql_idx], "chain"); - if (chain and start_idx > 0) return Error.InvalidFormat; + const is_else = std.mem.eql(u8, input[0..eql_idx], "else"); + if ((chain or is_else) and start_idx > 0) return Error.InvalidFormat; // Sequence iterator goes up to the equal, action is after. We can // parse the action now. @@ -137,11 +144,12 @@ pub const Parser = struct { // for chained inputs. The `next` will never yield this // because we have chain set. When we find a nicer way to // do this we can remove it, the e2e is tested. - .input = if (chain) "a" else input[0..eql_idx], + .input = if (chain or is_else) "a" else input[0..eql_idx], }, .action = try .parse(input[eql_idx + 1 ..]), .flags = flags, .chain = chain, + .is_else = is_else, }; } @@ -197,8 +205,9 @@ pub const Parser = struct { return .{ .leader = trigger }; } - // If we're a chain then return it as-is. + // If we're a chain or else then return it as-is. if (self.chain) return .{ .chain = self.action }; + if (self.is_else) return .{ .@"else" = self.action }; // Out of triggers, yield the final action. return .{ .binding = .{ @@ -255,6 +264,7 @@ fn parseSingle(raw_input: []const u8) (Error || error{ .leader => error.UnexpectedSequence, .binding => elem.binding, .chain => error.UnexpectedChain, + .@"else" => error.UnexpectedChain, }; } @@ -2056,6 +2066,8 @@ pub const Set = struct { key_ptr: *Trigger, value_ptr: *Value, set: *Set, + /// When true, subsequent chain= appends to the else branch. + in_else: bool = false, }; /// The entry type for the forward mapping of trigger to action. @@ -2133,8 +2145,21 @@ pub const Set = struct { .leaf => |leaf| { // When we get to the leaf, the buffer_stream contains // the full sequence of keys needed to reach this action. + const pos = buffer.end; buffer.print("={f}", .{leaf.action}) catch return error.OutOfMemory; try formatter.formatEntry([]const u8, buffer.buffer[0..buffer.end]); + + // Format else actions if present. + for (leaf.else_actions.items, 0..) |else_action, i| { + buffer.end = 0; + if (i == 0) { + buffer.print("else={f}", .{else_action}) catch return error.OutOfMemory; + } else { + buffer.print("chain={f}", .{else_action}) catch return error.OutOfMemory; + } + try formatter.formatEntry([]const u8, buffer.buffer[0..buffer.end]); + buffer.end = pos; + } }, .leaf_chained => |leaf| { @@ -2149,6 +2174,18 @@ pub const Set = struct { try formatter.formatEntry([]const u8, buffer.buffer[0..buffer.end]); buffer.end = pos; } + + // Format else actions if present. + for (leaf.else_actions.items, 0..) |else_action, i| { + buffer.end = 0; + if (i == 0) { + buffer.print("else={f}", .{else_action}) catch return error.OutOfMemory; + } else { + buffer.print("chain={f}", .{else_action}) catch return error.OutOfMemory; + } + try formatter.formatEntry([]const u8, buffer.buffer[0..buffer.end]); + buffer.end = pos; + } }, } } @@ -2159,17 +2196,38 @@ pub const Set = struct { pub const Leaf = struct { action: Action, flags: Flags, + else_actions: std.ArrayList(Action) = .empty, pub fn clone( self: Leaf, alloc: Allocator, ) Allocator.Error!Leaf { + var cloned_else = try self.else_actions.clone(alloc); + errdefer cloned_else.deinit(alloc); + for (cloned_else.items) |*action| { + action.* = try action.clone(alloc); + } return .{ .action = try self.action.clone(alloc), .flags = self.flags, + .else_actions = cloned_else, }; } + pub fn deinit(self: *Leaf, alloc: Allocator) void { + self.else_actions.deinit(alloc); + } + + pub fn equal(self: Leaf, other: Leaf) bool { + if (self.flags != other.flags) return false; + if (!deepEqual(Action, self.action, other.action)) return false; + if (self.else_actions.items.len != other.else_actions.items.len) return false; + for (self.else_actions.items, other.else_actions.items) |a1, a2| { + if (!deepEqual(Action, a1, a2)) return false; + } + return true; + } + pub fn hash(self: Leaf) u64 { var hasher = std.hash.Wyhash.init(0); self.action.hash(&hasher); @@ -2181,6 +2239,7 @@ pub const Set = struct { return .{ .flags = self.flags, .actions = .{ .single = .{self.action} }, + .else_actions = self.else_actions.items, }; } }; @@ -2189,6 +2248,7 @@ pub const Set = struct { pub const LeafChained = struct { actions: std.ArrayList(Action), flags: Flags, + else_actions: std.ArrayList(Action) = .empty, pub fn clone( self: LeafChained, @@ -2199,20 +2259,28 @@ pub const Set = struct { for (cloned_actions.items) |*action| { action.* = try action.clone(alloc); } + var cloned_else = try self.else_actions.clone(alloc); + errdefer cloned_else.deinit(alloc); + for (cloned_else.items) |*action| { + action.* = try action.clone(alloc); + } return .{ .actions = cloned_actions, .flags = self.flags, + .else_actions = cloned_else, }; } pub fn deinit(self: *LeafChained, alloc: Allocator) void { self.actions.deinit(alloc); + self.else_actions.deinit(alloc); } pub fn generic(self: *const LeafChained) GenericLeaf { return .{ .flags = self.flags, .actions = .{ .many = self.actions.items }, + .else_actions = self.else_actions.items, }; } }; @@ -2225,6 +2293,7 @@ pub const Set = struct { single: [1]Action, many: []const Action, }, + else_actions: []const Action = &.{}, pub fn actionsSlice(self: *const GenericLeaf) []const Action { return switch (self.actions) { @@ -2248,7 +2317,7 @@ pub const Set = struct { .leaf_chained => |*l| l.deinit(alloc), - .leaf => {}, + .leaf => |*l| l.deinit(alloc), }; self.bindings.deinit(alloc); @@ -2297,6 +2366,12 @@ pub const Set = struct { // If we had an invalid action for a chain (e.g. unbind). error.InvalidChainAction => return error.InvalidFormat, + // If else was used on a non-performable binding. + error.ElseRequiresPerformable => return error.InvalidFormat, + + // If else was used more than once on the same binding. + error.DuplicateElse => return error.InvalidFormat, + // Unrecoverable error.OutOfMemory => return error.OutOfMemory, } @@ -2324,6 +2399,8 @@ pub const Set = struct { NoChainParent, UnexpectedEndOfInput, InvalidChainAction, + ElseRequiresPerformable, + DuplicateElse, }; /// Returns the set that was ultimately updated if a binding was @@ -2363,6 +2440,8 @@ pub const Set = struct { error.NoChainParent, error.UnexpectedEndOfInput, error.InvalidChainAction, + error.ElseRequiresPerformable, + error.DuplicateElse, error.OutOfMemory, => err, }, @@ -2424,6 +2503,8 @@ pub const Set = struct { error.NoChainParent, error.UnexpectedEndOfInput, error.InvalidChainAction, + error.ElseRequiresPerformable, + error.DuplicateElse, error.OutOfMemory, => return err, }; @@ -2454,6 +2535,15 @@ pub const Set = struct { try set.appendChain(alloc, action); return set; }, + + .@"else" => |action| { + // Else can only happen on the root. + assert(set == root); + // Unbind is not valid for else. + if (action == .unbind) return error.InvalidChainAction; + try set.appendElse(alloc, action); + return set; + }, } } @@ -2510,14 +2600,17 @@ pub const Set = struct { // If we have an existing binding for this trigger, we have to // update the reverse mapping to remove the old action. - .leaf => if (track_reverse) { - const t_hash = t.hash(); - for (0.., self.reverse.values()) |i, *value| { - if (t_hash == value.hash()) { - self.reverse.swapRemoveAt(i); - break; + .leaf => |*leaf| { + if (track_reverse) { + const t_hash = t.hash(); + for (0.., self.reverse.values()) |i, *value| { + if (t_hash == value.hash()) { + self.reverse.swapRemoveAt(i); + break; + } } } + leaf.deinit(alloc); }, // Chained leaves aren't in the reverse mapping so we just @@ -2556,6 +2649,17 @@ pub const Set = struct { assert(action != .unbind); const parent = self.chain_parent orelse return error.NoChainParent; + + // If we're in the else branch, append to else_actions instead. + if (parent.in_else) { + switch (parent.value_ptr.*) { + .leader => unreachable, + .leaf => |*leaf| try leaf.else_actions.append(alloc, action), + .leaf_chained => |*leaf| try leaf.else_actions.append(alloc, action), + } + return; + } + switch (parent.value_ptr.*) { // Leader can never be a chain parent. Verified through various // assertions and unit tests. @@ -2587,6 +2691,7 @@ pub const Set = struct { parent.value_ptr.* = .{ .leaf_chained = .{ .actions = actions, .flags = leaf.flags, + .else_actions = leaf.else_actions, } }; // Clean up our reverse mapping. Chained actions are not @@ -2601,6 +2706,44 @@ pub const Set = struct { } } + /// Append an else fallback action to the prior set action. + /// + /// The else action is used as a fallback when a performable binding's + /// action cannot be performed. It is an error to use else on a + /// non-performable binding, or to use else more than once. + pub fn appendElse( + self: *Set, + alloc: Allocator, + action: Action, + ) (Allocator.Error || error{ NoChainParent, ElseRequiresPerformable, DuplicateElse })!void { + assert(action != .unbind); + + const parent = &(self.chain_parent orelse return error.NoChainParent); + + // Cannot use else if already in the else branch (duplicate else). + if (parent.in_else) return error.DuplicateElse; + + switch (parent.value_ptr.*) { + .leader => unreachable, + + .leaf => |*leaf| { + if (!leaf.flags.performable) return error.ElseRequiresPerformable; + // First else on a leaf — else_actions must be empty. + assert(leaf.else_actions.items.len == 0); + try leaf.else_actions.append(alloc, action); + }, + + .leaf_chained => |*leaf| { + if (!leaf.flags.performable) return error.ElseRequiresPerformable; + assert(leaf.else_actions.items.len == 0); + try leaf.else_actions.append(alloc, action); + }, + } + + // Switch to else branch so subsequent chains append there. + parent.in_else = true; + } + /// Get a binding for a given trigger. pub fn get(self: Set, t: Trigger) ?Entry { return self.bindings.getEntry(t); @@ -2683,10 +2826,13 @@ pub const Set = struct { }, // For an action we need to fix up the reverse mapping. - .leaf => |leaf| self.fixupReverseForAction( - leaf.action, - t, - ), + .leaf => |*leaf| { + self.fixupReverseForAction( + leaf.action, + t, + ); + leaf.deinit(alloc); + }, // Chained leaves are never in our reverse mapping so no // cleanup is required. @@ -4845,3 +4991,250 @@ test "set: formatEntries leaf_chained with text action" { ; try testing.expectEqualStrings(expected, output.written()); } + +test "parse: else" { + const testing = std.testing; + + // Valid + { + var p = try Parser.init("else=new_tab"); + try testing.expectEqual(Parser.Elem{ + .@"else" = .new_tab, + }, try p.next()); + try testing.expect(try p.next() == null); + } + + // Else can't have flags + try testing.expectError(error.InvalidFormat, Parser.init("global:else=ignore")); + try testing.expectError(error.InvalidFormat, Parser.init("performable:else=ignore")); +} + +test "set: parseAndPut else on performable" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "performable:a=new_window"); + try s.parseAndPut(alloc, "else=new_tab"); + + // Should still be a leaf with else_actions + { + const entry = s.get(.{ .key = .{ .unicode = 'a' } }).?.value_ptr.*; + try testing.expect(entry == .leaf); + const leaf = entry.leaf; + try testing.expect(leaf.action == .new_window); + try testing.expect(leaf.flags.performable); + try testing.expectEqual(@as(usize, 1), leaf.else_actions.items.len); + try testing.expect(leaf.else_actions.items[0] == .new_tab); + } +} + +test "set: parseAndPut else on non-performable is error" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "a=new_window"); + try testing.expectError(error.InvalidFormat, s.parseAndPut(alloc, "else=new_tab")); +} + +test "set: parseAndPut else without parent is error" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try testing.expectError(error.InvalidFormat, s.parseAndPut(alloc, "else=new_tab")); +} + +test "set: parseAndPut duplicate else is error" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "performable:a=new_window"); + try s.parseAndPut(alloc, "else=new_tab"); + try testing.expectError(error.InvalidFormat, s.parseAndPut(alloc, "else=close_surface")); +} + +test "set: parseAndPut chain then else" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + // performable action, chain on performed branch, then else branch + try s.parseAndPut(alloc, "performable:a=new_window"); + try s.parseAndPut(alloc, "chain=close_surface"); + try s.parseAndPut(alloc, "else=new_tab"); + + { + const entry = s.get(.{ .key = .{ .unicode = 'a' } }).?.value_ptr.*; + try testing.expect(entry == .leaf_chained); + const chained = entry.leaf_chained; + try testing.expect(chained.flags.performable); + try testing.expectEqual(@as(usize, 2), chained.actions.items.len); + try testing.expect(chained.actions.items[0] == .new_window); + try testing.expect(chained.actions.items[1] == .close_surface); + try testing.expectEqual(@as(usize, 1), chained.else_actions.items.len); + try testing.expect(chained.else_actions.items[0] == .new_tab); + } +} + +test "set: parseAndPut else then chain appends to else branch" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + // performable action, else branch, chain on else branch + try s.parseAndPut(alloc, "performable:a=new_window"); + try s.parseAndPut(alloc, "else=new_tab"); + try s.parseAndPut(alloc, "chain=close_surface"); + + { + const entry = s.get(.{ .key = .{ .unicode = 'a' } }).?.value_ptr.*; + try testing.expect(entry == .leaf); + const leaf = entry.leaf; + try testing.expect(leaf.action == .new_window); + try testing.expect(leaf.flags.performable); + try testing.expectEqual(@as(usize, 2), leaf.else_actions.items.len); + try testing.expect(leaf.else_actions.items[0] == .new_tab); + try testing.expect(leaf.else_actions.items[1] == .close_surface); + } +} + +test "set: parseAndPut else with unbind is error" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "performable:a=new_window"); + try testing.expectError(error.InvalidFormat, s.parseAndPut(alloc, "else=unbind")); +} + +test "set: parseAndPut chain on both branches" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + // Full config: performed branch with chains, then else branch with chains + try s.parseAndPut(alloc, "performable:a=new_window"); + try s.parseAndPut(alloc, "chain=close_surface"); + try s.parseAndPut(alloc, "else=new_tab"); + try s.parseAndPut(alloc, "chain=toggle_fullscreen"); + + { + const entry = s.get(.{ .key = .{ .unicode = 'a' } }).?.value_ptr.*; + try testing.expect(entry == .leaf_chained); + const chained = entry.leaf_chained; + + // Performed branch: new_window, close_surface + try testing.expectEqual(@as(usize, 2), chained.actions.items.len); + try testing.expect(chained.actions.items[0] == .new_window); + try testing.expect(chained.actions.items[1] == .close_surface); + + // Else branch: new_tab, toggle_fullscreen + try testing.expectEqual(@as(usize, 2), chained.else_actions.items.len); + try testing.expect(chained.else_actions.items[0] == .new_tab); + try testing.expect(chained.else_actions.items[1] == .toggle_fullscreen); + } +} + +test "set: clone with else_actions" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "performable:a=new_window"); + try s.parseAndPut(alloc, "else=new_tab"); + + var cloned = try s.clone(alloc); + defer cloned.deinit(alloc); + + const entry = cloned.get(.{ .key = .{ .unicode = 'a' } }).?.value_ptr.*; + try testing.expect(entry == .leaf); + try testing.expectEqual(@as(usize, 1), entry.leaf.else_actions.items.len); + try testing.expect(entry.leaf.else_actions.items[0] == .new_tab); +} + +test "set: formatEntries with else" { + const testing = std.testing; + const alloc = testing.allocator; + const formatterpkg = @import("../config/formatter.zig"); + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "performable:a=new_window"); + try s.parseAndPut(alloc, "else=new_tab"); + + const entry = s.get(.{ .key = .{ .unicode = 'a' } }).?; + + var output: std.Io.Writer.Allocating = .init(alloc); + defer output.deinit(); + + var buf: [1024]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + + try entry.key_ptr.format(&writer); + try entry.value_ptr.formatEntries(&writer, formatterpkg.entryFormatter("keybind", &output.writer)); + + // Note: the trigger format does not include flag prefixes like performable: + // so the serialized output won't have it. This is an existing limitation. + const expected = + \\keybind = a=new_window + \\keybind = else=new_tab + \\ + ; + try testing.expectEqualStrings(expected, output.written()); +} + +test "set: formatEntries with chain and else" { + const testing = std.testing; + const alloc = testing.allocator; + const formatterpkg = @import("../config/formatter.zig"); + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "performable:a=new_window"); + try s.parseAndPut(alloc, "chain=close_surface"); + try s.parseAndPut(alloc, "else=new_tab"); + try s.parseAndPut(alloc, "chain=toggle_fullscreen"); + + const entry = s.get(.{ .key = .{ .unicode = 'a' } }).?; + + var output: std.Io.Writer.Allocating = .init(alloc); + defer output.deinit(); + + var buf: [1024]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + + try entry.key_ptr.format(&writer); + try entry.value_ptr.formatEntries(&writer, formatterpkg.entryFormatter("keybind", &output.writer)); + + const expected = + \\keybind = a=new_window + \\keybind = chain=close_surface + \\keybind = else=new_tab + \\keybind = chain=toggle_fullscreen + \\ + ; + try testing.expectEqualStrings(expected, output.written()); +} From 3c90eea99294c550861bbe760e8facd0dddd5907 Mon Sep 17 00:00:00 2001 From: "Steven Lu (MBP M1 Max)" <1542910+unphased@users.noreply.github.com> Date: Mon, 16 Mar 2026 18:53:34 -0400 Subject: [PATCH 2/2] keybind: add comprehensive tests for else= fallback Add tests for: - Multiple chains on the else branch - Multiple chains on the performed branch before else - Else with text/parameterized actions (allocated data) - Clone with leaf_chained and else_actions - Clone with text else_actions has independent memory - Overwriting a binding with else_actions cleans up - New binding after else resets chain context - Else after unbind is error - GenericLeaf exposes else_actions for both leaf and leaf_chained - Performable without else has empty else_actions - Non-performable binding has empty else_actions Co-Authored-By: Claude Opus 4.6 --- src/input/Binding.zig | 286 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 286 insertions(+) diff --git a/src/input/Binding.zig b/src/input/Binding.zig index c74238a75..d2abe1dcb 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -5238,3 +5238,289 @@ test "set: formatEntries with chain and else" { ; try testing.expectEqualStrings(expected, output.written()); } + +test "set: parseAndPut multiple chains on else branch" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "performable:a=new_window"); + try s.parseAndPut(alloc, "else=new_tab"); + try s.parseAndPut(alloc, "chain=close_surface"); + try s.parseAndPut(alloc, "chain=toggle_fullscreen"); + + { + const entry = s.get(.{ .key = .{ .unicode = 'a' } }).?.value_ptr.*; + try testing.expect(entry == .leaf); + const leaf = entry.leaf; + + // Primary branch: just new_window (no chains before else) + try testing.expect(leaf.action == .new_window); + + // Else branch: new_tab, close_surface, toggle_fullscreen + try testing.expectEqual(@as(usize, 3), leaf.else_actions.items.len); + try testing.expect(leaf.else_actions.items[0] == .new_tab); + try testing.expect(leaf.else_actions.items[1] == .close_surface); + try testing.expect(leaf.else_actions.items[2] == .toggle_fullscreen); + } +} + +test "set: parseAndPut multiple chains on performed branch before else" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "performable:a=new_window"); + try s.parseAndPut(alloc, "chain=close_surface"); + try s.parseAndPut(alloc, "chain=toggle_fullscreen"); + try s.parseAndPut(alloc, "else=new_tab"); + + { + const entry = s.get(.{ .key = .{ .unicode = 'a' } }).?.value_ptr.*; + try testing.expect(entry == .leaf_chained); + const chained = entry.leaf_chained; + + // Performed branch: new_window, close_surface, toggle_fullscreen + try testing.expectEqual(@as(usize, 3), chained.actions.items.len); + try testing.expect(chained.actions.items[0] == .new_window); + try testing.expect(chained.actions.items[1] == .close_surface); + try testing.expect(chained.actions.items[2] == .toggle_fullscreen); + + // Else branch: just new_tab + try testing.expectEqual(@as(usize, 1), chained.else_actions.items.len); + try testing.expect(chained.else_actions.items[0] == .new_tab); + } +} + +test "set: parseAndPut else with text actions" { + const testing = std.testing; + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var s: Set = .{}; + + try s.parseAndPut(alloc, "performable:a=text:hello"); + try s.parseAndPut(alloc, "else=text:world"); + + { + const entry = s.get(.{ .key = .{ .unicode = 'a' } }).?.value_ptr.*; + try testing.expect(entry == .leaf); + try testing.expectEqualStrings("hello", entry.leaf.action.text); + try testing.expectEqual(@as(usize, 1), entry.leaf.else_actions.items.len); + try testing.expectEqualStrings("world", entry.leaf.else_actions.items[0].text); + } +} + +test "set: clone with leaf_chained and else_actions" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "performable:a=new_window"); + try s.parseAndPut(alloc, "chain=close_surface"); + try s.parseAndPut(alloc, "else=new_tab"); + try s.parseAndPut(alloc, "chain=toggle_fullscreen"); + + var cloned = try s.clone(alloc); + defer cloned.deinit(alloc); + + const entry = cloned.get(.{ .key = .{ .unicode = 'a' } }).?.value_ptr.*; + try testing.expect(entry == .leaf_chained); + + // Performed branch preserved + try testing.expectEqual(@as(usize, 2), entry.leaf_chained.actions.items.len); + try testing.expect(entry.leaf_chained.actions.items[0] == .new_window); + try testing.expect(entry.leaf_chained.actions.items[1] == .close_surface); + + // Else branch preserved + try testing.expectEqual(@as(usize, 2), entry.leaf_chained.else_actions.items.len); + try testing.expect(entry.leaf_chained.else_actions.items[0] == .new_tab); + try testing.expect(entry.leaf_chained.else_actions.items[1] == .toggle_fullscreen); +} + +test "set: clone with text else_actions has independent memory" { + const testing = std.testing; + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var s: Set = .{}; + + try s.parseAndPut(alloc, "performable:a=text:hello"); + try s.parseAndPut(alloc, "else=text:world"); + + const cloned = try s.clone(alloc); + + const orig_entry = s.get(.{ .key = .{ .unicode = 'a' } }).?.value_ptr.*; + const cloned_entry = cloned.get(.{ .key = .{ .unicode = 'a' } }).?.value_ptr.*; + + // Verify the cloned else action has the same content + try testing.expectEqualStrings("world", cloned_entry.leaf.else_actions.items[0].text); + + // Verify the pointers are different (independent allocation) + try testing.expect(orig_entry.leaf.else_actions.items[0].text.ptr != + cloned_entry.leaf.else_actions.items[0].text.ptr); +} + +test "set: overwrite binding with else_actions cleans up" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + // Create a binding with else_actions + try s.parseAndPut(alloc, "performable:a=new_window"); + try s.parseAndPut(alloc, "else=new_tab"); + + // Overwrite with a new binding — old else_actions must be freed + try s.parseAndPut(alloc, "a=close_surface"); + + { + const entry = s.get(.{ .key = .{ .unicode = 'a' } }).?.value_ptr.*; + try testing.expect(entry == .leaf); + try testing.expect(entry.leaf.action == .close_surface); + // New binding should have no else_actions + try testing.expectEqual(@as(usize, 0), entry.leaf.else_actions.items.len); + } +} + +test "set: new binding after else resets chain context" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + // First binding with else + try s.parseAndPut(alloc, "performable:a=new_window"); + try s.parseAndPut(alloc, "else=new_tab"); + + // New binding should start fresh, not in else mode + try s.parseAndPut(alloc, "b=close_surface"); + try s.parseAndPut(alloc, "chain=toggle_fullscreen"); + + { + // Verify first binding unchanged + const a_entry = s.get(.{ .key = .{ .unicode = 'a' } }).?.value_ptr.*; + try testing.expect(a_entry == .leaf); + try testing.expectEqual(@as(usize, 1), a_entry.leaf.else_actions.items.len); + + // Verify second binding has chain on primary (not else) + const b_entry = s.get(.{ .key = .{ .unicode = 'b' } }).?.value_ptr.*; + try testing.expect(b_entry == .leaf_chained); + try testing.expectEqual(@as(usize, 2), b_entry.leaf_chained.actions.items.len); + try testing.expect(b_entry.leaf_chained.actions.items[0] == .close_surface); + try testing.expect(b_entry.leaf_chained.actions.items[1] == .toggle_fullscreen); + // No else on second binding + try testing.expectEqual(@as(usize, 0), b_entry.leaf_chained.else_actions.items.len); + } +} + +test "set: else after unbind is error" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "performable:a=new_window"); + try s.parseAndPut(alloc, "a=unbind"); + + // chain_parent cleared by unbind, so else should fail + try testing.expectError(error.InvalidFormat, s.parseAndPut(alloc, "else=new_tab")); +} + +test "set: generic leaf exposes else_actions" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "performable:a=new_window"); + try s.parseAndPut(alloc, "else=new_tab"); + try s.parseAndPut(alloc, "chain=close_surface"); + + { + const entry = s.get(.{ .key = .{ .unicode = 'a' } }).?.value_ptr.*; + try testing.expect(entry == .leaf); + const generic = entry.leaf.generic(); + + // Primary actions + const actions = generic.actionsSlice(); + try testing.expectEqual(@as(usize, 1), actions.len); + try testing.expect(actions[0] == .new_window); + + // Else actions accessible via generic + try testing.expectEqual(@as(usize, 2), generic.else_actions.len); + try testing.expect(generic.else_actions[0] == .new_tab); + try testing.expect(generic.else_actions[1] == .close_surface); + } +} + +test "set: generic leaf_chained exposes else_actions" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "performable:a=new_window"); + try s.parseAndPut(alloc, "chain=close_surface"); + try s.parseAndPut(alloc, "else=new_tab"); + + { + const entry = s.get(.{ .key = .{ .unicode = 'a' } }).?.value_ptr.*; + try testing.expect(entry == .leaf_chained); + const generic = entry.leaf_chained.generic(); + + // Primary actions + const actions = generic.actionsSlice(); + try testing.expectEqual(@as(usize, 2), actions.len); + + // Else actions accessible via generic + try testing.expectEqual(@as(usize, 1), generic.else_actions.len); + try testing.expect(generic.else_actions[0] == .new_tab); + } +} + +test "set: performable without else has empty else_actions" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "performable:a=new_window"); + + { + const entry = s.get(.{ .key = .{ .unicode = 'a' } }).?.value_ptr.*; + try testing.expect(entry == .leaf); + const generic = entry.leaf.generic(); + try testing.expectEqual(@as(usize, 0), generic.else_actions.len); + } +} + +test "set: non-performable binding has empty else_actions" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "a=new_window"); + + { + const entry = s.get(.{ .key = .{ .unicode = 'a' } }).?.value_ptr.*; + try testing.expect(entry == .leaf); + try testing.expectEqual(@as(usize, 0), entry.leaf.else_actions.items.len); + } +}