From 0c212fefc417ff479e01da2b583cac0b69e6a187 Mon Sep 17 00:00:00 2001 From: foxnne Date: Mon, 19 Oct 2026 08:38:37 -0500 Subject: [PATCH 1/2] guides: a guide folds and unfolds, and stays as left A tree branch is drawn as it is asked to be each frame and toggles itself to match, and the guides view asked for "open if first" every frame: the first guide reopened the moment it was folded, and the last, unfolded, folded and unfolded again without end. The view now remembers each guide as the person left it, by name, as the settings and keybind trees do; a guide never touched is open if it is the first. Part of fizzyedit/plans#30. Co-Authored-By: Claude Opus 5.5 --- plugins/guides/plugin.zig | 2 +- plugins/guides/src/List.zig | 18 ++++++++++++------ 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/plugins/guides/plugin.zig b/plugins/guides/plugin.zig index 5cbe8a80d..83070fff0 100644 --- a/plugins/guides/plugin.zig +++ b/plugins/guides/plugin.zig @@ -54,7 +54,7 @@ pub fn register(host: *sdk.Host) !void { fn drawView(_: ?*anyopaque) anyerror!dvui.App.Result { const host = host_ptr orelse return .ok; - List.draw(host.getServiceTyped(sdk.services.demos.Api)); + List.draw(host.allocator, host.getServiceTyped(sdk.services.demos.Api)); return .ok; } diff --git a/plugins/guides/src/List.zig b/plugins/guides/src/List.zig index 067b7d0a5..9873115dc 100644 --- a/plugins/guides/src/List.zig +++ b/plugins/guides/src/List.zig @@ -11,7 +11,12 @@ const core = @import("core"); const Api = sdk.services.demos.Api; -pub fn draw(service: ?*Api) void { +/// Which guides are unfolded, by a hash of the name: a branch is drawn as it is asked to be each +/// frame, and toggles itself to match, so the tree remembers what the person left it as. A guide +/// never folded or unfolded is open if it is the first. +var unfolded: std.AutoHashMapUnmanaged(u64, bool) = .empty; + +pub fn draw(gpa: std.mem.Allocator, service: ?*Api) void { var scroll = dvui.scrollArea(@src(), .{ .horizontal = .none, .vertical_bar = .auto_overlay }, .{ .expand = .both, .background = false }); defer scroll.deinit(); @@ -29,13 +34,15 @@ pub fn draw(service: ?*Api) void { if (row(tree, i, d.title, d.ms, current, .guide_leaf)) _ = api.play(d.name, 0); continue; } - const b = branch(tree, i, d, current); + const key = std.hash.Wyhash.hash(0, d.name); + const b = branch(tree, i, d, current, unfolded.get(key) orelse (i == 0)); defer b.deinit(); if (b.expander(@src(), .{ .indent = 14 }, .{ .expand = .horizontal })) { for (d.chapters, 0..) |c, j| { if (row(tree, i * 1000 + j + 1, c.title, c.at, false, .chapter)) _ = api.play(d.name, c.at); } } + unfolded.put(gpa, key, b.expanded) catch {}; } } @@ -43,11 +50,10 @@ fn note(text: []const u8) void { dvui.labelNoFmt(@src(), text, .{}, .{ .color_text = .{ .color = dim() }, .padding = .all(6) }); } -/// A demo with chapters: its caret folds them. The first demo starts open, so the view shows how -/// it is used; after that each keeps what the person left it as. -fn branch(tree: *core.widgets.TreeWidget, i: usize, d: Api.Info, current: bool) *core.widgets.TreeWidget.Branch { +/// A demo with chapters: its caret folds them, `open` as the person left it (`unfolded`). +fn branch(tree: *core.widgets.TreeWidget, i: usize, d: Api.Info, current: bool, open: bool) *core.widgets.TreeWidget.Branch { const theme = dvui.themeGet(); - const b = tree.branch(@src(), .{ .expanded = i == 0, .draggable = false }, rowOptions(i)); + const b = tree.branch(@src(), .{ .expanded = open, .draggable = false }, rowOptions(i)); core.widgets.treeCaret(@src(), b.expanded, theme.color(.control, .fill)); glyph(current); label(d.title, d.ms, theme.color(.control, .text)); From b733ef86bc4cc42f63b7c3778be6a0f62d4a5683 Mon Sep 17 00:00:00 2001 From: foxnne Date: Mon, 19 Oct 2026 09:07:44 -0500 Subject: [PATCH 2/2] guides: free the fold state when the plugin unloads The map of unfolded guides was allocated from host.allocator and never freed, so the verdict run's debug allocator reported a leak at exit. The plugin now has a deinit that frees it. The map is written only when a guide's fold changes, not every frame. --- plugins/guides/plugin.zig | 8 +++++++- plugins/guides/src/List.zig | 11 +++++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/plugins/guides/plugin.zig b/plugins/guides/plugin.zig index 83070fff0..d8ffb050c 100644 --- a/plugins/guides/plugin.zig +++ b/plugins/guides/plugin.zig @@ -26,7 +26,7 @@ var plugin: sdk.Plugin = .{ .display_name = plugin_options.name, }; -const vtable: sdk.Plugin.VTable = .{}; +const vtable: sdk.Plugin.VTable = .{ .deinit = deinit }; var host_ptr: ?*sdk.Host = null; @@ -52,6 +52,12 @@ pub fn register(host: *sdk.Host) !void { }); } +fn deinit(state: *anyopaque) void { + const host: *sdk.Host = @ptrCast(@alignCast(state)); + List.deinit(host.allocator); + host_ptr = null; +} + fn drawView(_: ?*anyopaque) anyerror!dvui.App.Result { const host = host_ptr orelse return .ok; List.draw(host.allocator, host.getServiceTyped(sdk.services.demos.Api)); diff --git a/plugins/guides/src/List.zig b/plugins/guides/src/List.zig index 9873115dc..1f9434dd6 100644 --- a/plugins/guides/src/List.zig +++ b/plugins/guides/src/List.zig @@ -16,6 +16,12 @@ const Api = sdk.services.demos.Api; /// never folded or unfolded is open if it is the first. var unfolded: std.AutoHashMapUnmanaged(u64, bool) = .empty; +/// Forgets what was folded; the plugin's `deinit` calls it with the allocator `draw` was given. +pub fn deinit(gpa: std.mem.Allocator) void { + unfolded.deinit(gpa); + unfolded = .empty; +} + pub fn draw(gpa: std.mem.Allocator, service: ?*Api) void { var scroll = dvui.scrollArea(@src(), .{ .horizontal = .none, .vertical_bar = .auto_overlay }, .{ .expand = .both, .background = false }); defer scroll.deinit(); @@ -35,14 +41,15 @@ pub fn draw(gpa: std.mem.Allocator, service: ?*Api) void { continue; } const key = std.hash.Wyhash.hash(0, d.name); - const b = branch(tree, i, d, current, unfolded.get(key) orelse (i == 0)); + const open = unfolded.get(key) orelse (i == 0); + const b = branch(tree, i, d, current, open); defer b.deinit(); if (b.expander(@src(), .{ .indent = 14 }, .{ .expand = .horizontal })) { for (d.chapters, 0..) |c, j| { if (row(tree, i * 1000 + j + 1, c.title, c.at, false, .chapter)) _ = api.play(d.name, c.at); } } - unfolded.put(gpa, key, b.expanded) catch {}; + if (b.expanded != open) unfolded.put(gpa, key, b.expanded) catch {}; } }