diff --git a/src/config/Config.zig b/src/config/Config.zig index 7771a60ec..86d045c6a 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -4234,14 +4234,9 @@ pub const Keybinds = struct { } } - try formatter.formatEntry( - []const u8, - std.fmt.bufPrint( - &buf, - "{}{}", - .{ k, v }, - ) catch return error.OutOfMemory, - ); + var buffer_stream = std.io.fixedBufferStream(&buf); + std.fmt.format(buffer_stream.writer(), "{}", .{k}) catch return error.OutOfMemory; + try v.formatEntries(&buffer_stream, formatter); } } @@ -4275,6 +4270,56 @@ pub const Keybinds = struct { try list.formatEntry(formatterpkg.entryFormatter("a", buf.writer())); try std.testing.expectEqualSlices(u8, "a = shift+a=csi:hello\n", buf.items); } + + // Regression test for https://github.com/ghostty-org/ghostty/issues/2734 + test "formatConfig multiple items" { + const testing = std.testing; + var buf = std.ArrayList(u8).init(testing.allocator); + defer buf.deinit(); + + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var list: Keybinds = .{}; + try list.parseCLI(alloc, "ctrl+z>1=goto_tab:1"); + try list.parseCLI(alloc, "ctrl+z>2=goto_tab:2"); + try list.formatEntry(formatterpkg.entryFormatter("keybind", buf.writer())); + + const want = + \\keybind = ctrl+z>1=goto_tab:1 + \\keybind = ctrl+z>2=goto_tab:2 + \\ + ; + try std.testing.expectEqualStrings(want, buf.items); + } + + test "formatConfig multiple items nested" { + const testing = std.testing; + var buf = std.ArrayList(u8).init(testing.allocator); + defer buf.deinit(); + + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var list: Keybinds = .{}; + try list.parseCLI(alloc, "ctrl+a>ctrl+b>n=new_window"); + try list.parseCLI(alloc, "ctrl+a>ctrl+b>w=close_window"); + try list.parseCLI(alloc, "ctrl+a>ctrl+c>t=new_tab"); + try list.parseCLI(alloc, "ctrl+b>ctrl+d>a=previous_tab"); + try list.formatEntry(formatterpkg.entryFormatter("a", buf.writer())); + + // NB: This does not currently retain the order of the keybinds. + const want = + \\a = ctrl+a>ctrl+b>w=close_window + \\a = ctrl+a>ctrl+b>n=new_window + \\a = ctrl+a>ctrl+c>t=new_tab + \\a = ctrl+b>ctrl+d>a=previous_tab + \\ + ; + try std.testing.expectEqualStrings(want, buf.items); + } }; /// See "font-codepoint-map" for documentation. diff --git a/src/input/Binding.zig b/src/input/Binding.zig index a467bfc2b..b451b5ec9 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -1149,6 +1149,41 @@ pub const Set = struct { }, } } + + /// Writes the configuration entries for the binding + /// that this value is part of. + /// + /// The value may be part of multiple configuration entries + /// if they're all part of the same prefix sequence (e.g. 'a>b', 'a>c'). + /// These will result in multiple separate entries in the configuration. + /// + /// `buffer_stream` is a FixedBufferStream used for temporary storage + /// that is shared between calls to nested levels of the set. + /// For example, 'a>b>c=x' and 'a>b>d=y' will re-use the 'a>b' written + /// to the buffer before flushing it to the formatter with 'c=x' and 'd=y'. + pub fn formatEntries(self: Value, buffer_stream: anytype, formatter: anytype) !void { + switch (self) { + .leader => |set| { + // We'll rewind to this position after each sub-entry, + // sharing the prefix between siblings. + const pos = try buffer_stream.getPos(); + + var iter = set.bindings.iterator(); + while (iter.next()) |binding| { + buffer_stream.seekTo(pos) catch unreachable; // can't fail + std.fmt.format(buffer_stream.writer(), ">{s}", .{binding.key_ptr.*}) catch return error.OutOfMemory; + try binding.value_ptr.*.formatEntries(buffer_stream, formatter); + } + }, + + .leaf => |leaf| { + // When we get to the leaf, the buffer_stream contains + // the full sequence of keys needed to reach this action. + std.fmt.format(buffer_stream.writer(), "={s}", .{leaf.action}) catch return error.OutOfMemory; + try formatter.formatEntry([]const u8, buffer_stream.getWritten()); + }, + } + } }; /// Leaf node of a set is an action to trigger. This is a "leaf" compared