From 5a1752cff879293e4174226cfe47683b8fed8f26 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 15:40:08 +0000 Subject: [PATCH 1/2] core: a key fold in a tree stays folded A branch treated init_options.expanded as a controlled value, so a Left/Right fold was undone in the same frame whenever the caller passed a stale open state. The caller's value now drives the branch only when it changes. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_016JNGGzdR8958EwaYZWPz9w --- core/widgets/TreeWidget.zig | 12 ++++++++- tests/integration.zig | 50 +++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/core/widgets/TreeWidget.zig b/core/widgets/TreeWidget.zig index 66ddc5531..a2f8c8250 100644 --- a/core/widgets/TreeWidget.zig +++ b/core/widgets/TreeWidget.zig @@ -426,6 +426,12 @@ pub const Branch = struct { /// inside it are carried with it. carried_source: bool = false, expanded: bool = false, + /// `init_options.expanded` as the caller passed it last frame (this frame's, on the first). + /// The caller's value drives the branch only when it *changes*; otherwise the branch's own + /// state (a key fold, a click) wins, so a caller passing the same stale value every frame + /// cannot undo it. + last_init: bool = false, + passed_init: bool = false, can_expand: bool = false, anim: ?*dvui.AnimateWidget = null, /// Populated while `expander_vbox` is active: clip children to `anim`'s content (animated height). @@ -466,6 +472,8 @@ pub const Branch = struct { self.options = defaults.override(opts); self.wd = WidgetData.init(src, .{}, wrapOuter(self.options).override(.{ .rect = .{} })); self.expanded = if (dvui.dataGet(null, self.wd.id, "_expanded", bool)) |e| e else init_opts.expanded; + self.passed_init = init_opts.expanded; + self.last_init = if (dvui.dataGet(null, self.wd.id, "_init_expanded", bool)) |e| e else init_opts.expanded; } // can call this after init before install @@ -767,7 +775,8 @@ pub const Branch = struct { default_opts.override(opts), ); - if (clicked or self.init_options.expanded != self.expanded) { + const driven = self.init_options.expanded != self.last_init and self.init_options.expanded != self.expanded; + if (clicked or driven) { if (self.expanded) { self.anim.?.init_opts.easing = motion.settle; self.anim.?.init_opts.duration = motion.duration(@divTrunc(self.init_options.animation_duration, 2)); @@ -896,6 +905,7 @@ pub const Branch = struct { } dvui.dataSet(null, self.data().id, "_expanded", self.expanded); + dvui.dataSet(null, self.data().id, "_init_expanded", self.passed_init); } else { self.hbox.deinit(); self.button.deinit(); diff --git a/tests/integration.zig b/tests/integration.zig index dc781c089..c7a69c0c4 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -7242,6 +7242,56 @@ test "tree: a drag put down is not dropped where the pointer last was" { try std.testing.expect(CarriedTree.landed == null); } +// A key fold sticks. The caller below passes the same `.expanded = true` every frame, as a list +// whose open state lives elsewhere does until it reads `branch.expanded` back: Left must still +// leave the branch folded next frame, and Right open it again. +const KeyFoldTree = struct { + const TW = fizzy.core.widgets.TreeWidget; + + var focus = false; + var expanded = false; + var body_drawn = false; + + fn frame() anyerror!dvui.App.Result { + var tree = TW.tree(@src(), .{}, .{ .expand = .both }); + defer tree.deinit(); + const branch = tree.branch(@src(), .{ .expanded = true, .animation_duration = 0 }, .{ .expand = .horizontal }); + defer branch.deinit(); + if (focus) { + focus = false; + dvui.focusWidget(branch.button.data().id, null, null); + } + dvui.labelNoFmt(@src(), "row", .{}, .{ .min_size_content = .{ .w = 120, .h = 20 } }); + body_drawn = false; + if (branch.expander(@src(), .{}, .{ .expand = .horizontal })) { + body_drawn = true; + dvui.labelNoFmt(@src(), "child", .{}, .{ .min_size_content = .{ .w = 120, .h = 20 } }); + } + expanded = branch.expanded; + return .ok; + } +}; + +test "tree: a key fold stays folded when the caller keeps passing the same open state" { + var t = try dvui.testing.init(.{ .allocator = std.testing.allocator }); + defer t.deinit(); + KeyFoldTree.focus = true; + try dvui.testing.settle(KeyFoldTree.frame); + try std.testing.expect(KeyFoldTree.expanded and KeyFoldTree.body_drawn); + + try dvui.testing.pressKey(.left, .none); + try dvui.testing.settle(KeyFoldTree.frame); + try std.testing.expect(!KeyFoldTree.expanded and !KeyFoldTree.body_drawn); + _ = try dvui.testing.step(KeyFoldTree.frame); + try std.testing.expect(!KeyFoldTree.expanded); + + try dvui.testing.pressKey(.right, .none); + try dvui.testing.settle(KeyFoldTree.frame); + try std.testing.expect(KeyFoldTree.expanded and KeyFoldTree.body_drawn); + _ = try dvui.testing.step(KeyFoldTree.frame); + try std.testing.expect(KeyFoldTree.expanded); +} + test "a frost never captures more than the picture it reads" { const within = fizzy.core.widgets.BlurBackdrop.within; const Rect = dvui.Rect.Physical; From 1c4631fddb90cd0a240a1591ff235414134304d9 Mon Sep 17 00:00:00 2001 From: foxnne Date: Mon, 19 Oct 2026 12:17:50 -0500 Subject: [PATCH 2/2] core: a key fold goes through the expander, as a click does Left/Right set `expanded` in `install()`, before `expander()` compared it with the caller's `init_options.expanded`, which the caller has not yet read back: the fold was undone in the same frame. The previous take kept the caller's last value and drove the branch only on a change, which lost a caller's change made during the close animation, stopped hover auto-open on a branch a filter forces open, and broke clearing a search back to the saved state. Instead the key asks for a toggle and `expander()` takes it beside `clicked`: one path, animated, and the caller's value keeps meaning what it did. Co-Authored-By: Claude Opus 5.5 --- core/widgets/TreeWidget.zig | 20 +++++++------------- tests/integration.zig | 28 ++++++++++++++++------------ 2 files changed, 23 insertions(+), 25 deletions(-) diff --git a/core/widgets/TreeWidget.zig b/core/widgets/TreeWidget.zig index a2f8c8250..7f329db0a 100644 --- a/core/widgets/TreeWidget.zig +++ b/core/widgets/TreeWidget.zig @@ -426,12 +426,10 @@ pub const Branch = struct { /// inside it are carried with it. carried_source: bool = false, expanded: bool = false, - /// `init_options.expanded` as the caller passed it last frame (this frame's, on the first). - /// The caller's value drives the branch only when it *changes*; otherwise the branch's own - /// state (a key fold, a click) wins, so a caller passing the same stale value every frame - /// cannot undo it. - last_init: bool = false, - passed_init: bool = false, + /// Left/Right pressed on this row this frame: `expander()` toggles the branch the way a + /// click does (animated, and before the caller reads `expanded` back), so a caller passing + /// last frame's open state cannot undo it. + key_toggle: bool = false, can_expand: bool = false, anim: ?*dvui.AnimateWidget = null, /// Populated while `expander_vbox` is active: clip children to `anim`'s content (animated height). @@ -472,8 +470,6 @@ pub const Branch = struct { self.options = defaults.override(opts); self.wd = WidgetData.init(src, .{}, wrapOuter(self.options).override(.{ .rect = .{} })); self.expanded = if (dvui.dataGet(null, self.wd.id, "_expanded", bool)) |e| e else init_opts.expanded; - self.passed_init = init_opts.expanded; - self.last_init = if (dvui.dataGet(null, self.wd.id, "_init_expanded", bool)) |e| e else init_opts.expanded; } // can call this after init before install @@ -711,13 +707,13 @@ pub const Branch = struct { if (self.expanded) { dvui.tabIndexNextEx(e.num, self.tree.group.tab_index_prev, self.tree.data().id, false); } else { - self.expanded = true; + self.key_toggle = true; } }, .left => { e.handle(@src(), self.button.data()); if (self.expanded) { - self.expanded = false; + self.key_toggle = true; } else if (self.parent_focus_id) |pid| { dvui.focusWidget(pid, null, e.num); } else { @@ -775,8 +771,7 @@ pub const Branch = struct { default_opts.override(opts), ); - const driven = self.init_options.expanded != self.last_init and self.init_options.expanded != self.expanded; - if (clicked or driven) { + if (clicked or self.key_toggle or self.init_options.expanded != self.expanded) { if (self.expanded) { self.anim.?.init_opts.easing = motion.settle; self.anim.?.init_opts.duration = motion.duration(@divTrunc(self.init_options.animation_duration, 2)); @@ -905,7 +900,6 @@ pub const Branch = struct { } dvui.dataSet(null, self.data().id, "_expanded", self.expanded); - dvui.dataSet(null, self.data().id, "_init_expanded", self.passed_init); } else { self.hbox.deinit(); self.button.deinit(); diff --git a/tests/integration.zig b/tests/integration.zig index c7a69c0c4..f1d96f3ef 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -7242,20 +7242,20 @@ test "tree: a drag put down is not dropped where the pointer last was" { try std.testing.expect(CarriedTree.landed == null); } -// A key fold sticks. The caller below passes the same `.expanded = true` every frame, as a list -// whose open state lives elsewhere does until it reads `branch.expanded` back: Left must still -// leave the branch folded next frame, and Right open it again. +// A key fold sticks. The caller below keeps its own open state and passes it back each frame, as +// the settings, file and guides trees do, reading `branch.expanded` after `expander()`: Left must +// fold the branch (animated, as a click does) and leave it folded, and Right open it again. const KeyFoldTree = struct { const TW = fizzy.core.widgets.TreeWidget; var focus = false; - var expanded = false; + var open = true; var body_drawn = false; fn frame() anyerror!dvui.App.Result { var tree = TW.tree(@src(), .{}, .{ .expand = .both }); defer tree.deinit(); - const branch = tree.branch(@src(), .{ .expanded = true, .animation_duration = 0 }, .{ .expand = .horizontal }); + const branch = tree.branch(@src(), .{ .expanded = open }, .{ .expand = .horizontal }); defer branch.deinit(); if (focus) { focus = false; @@ -7267,29 +7267,33 @@ const KeyFoldTree = struct { body_drawn = true; dvui.labelNoFmt(@src(), "child", .{}, .{ .min_size_content = .{ .w = 120, .h = 20 } }); } - expanded = branch.expanded; + open = branch.expanded; return .ok; } }; -test "tree: a key fold stays folded when the caller keeps passing the same open state" { +test "tree: a key fold stays folded when the caller passes its open state back" { var t = try dvui.testing.init(.{ .allocator = std.testing.allocator }); defer t.deinit(); KeyFoldTree.focus = true; + KeyFoldTree.open = true; try dvui.testing.settle(KeyFoldTree.frame); - try std.testing.expect(KeyFoldTree.expanded and KeyFoldTree.body_drawn); + try std.testing.expect(KeyFoldTree.open and KeyFoldTree.body_drawn); try dvui.testing.pressKey(.left, .none); + // The fold animates: the body is still drawn the frame after the key, closing. + _ = try dvui.testing.step(KeyFoldTree.frame); + try std.testing.expect(KeyFoldTree.body_drawn); try dvui.testing.settle(KeyFoldTree.frame); - try std.testing.expect(!KeyFoldTree.expanded and !KeyFoldTree.body_drawn); + try std.testing.expect(!KeyFoldTree.open and !KeyFoldTree.body_drawn); _ = try dvui.testing.step(KeyFoldTree.frame); - try std.testing.expect(!KeyFoldTree.expanded); + try std.testing.expect(!KeyFoldTree.open); try dvui.testing.pressKey(.right, .none); try dvui.testing.settle(KeyFoldTree.frame); - try std.testing.expect(KeyFoldTree.expanded and KeyFoldTree.body_drawn); + try std.testing.expect(KeyFoldTree.open and KeyFoldTree.body_drawn); _ = try dvui.testing.step(KeyFoldTree.frame); - try std.testing.expect(KeyFoldTree.expanded); + try std.testing.expect(KeyFoldTree.open); } test "a frost never captures more than the picture it reads" {