Skip to content

Add collapsible Branch and Leaf nodes to the Tree widget - #419

Merged
matt-edmondson merged 3 commits into
mainfrom
feat/tree-branch-leaf
Sep 16, 2026
Merged

matt-edmondson merged 3 commits into
mainfrom
feat/tree-branch-leaf

Conversation

@matt-edmondson

Copy link
Copy Markdown
Contributor

Written by Claude (Claude Code) on behalf of Matt Edmondson.

Tree drew connector lines around whatever was nested inside it and nothing else, so a tree that actually collapses meant pairing it with ImGui.TreeNodeEx yourself. That pairing has a trap in it: Tree indents its own contents, ImGui's tree push indents them again, and every level marches further right than the lines are drawn. NoTreePushOnOpen on each node is the fix, and it is discoverable from neither API.

Branch and Leaf do that pairing correctly.

ImGuiWidgets.Tree.Branch("Fruit", () =>
{
    ImGuiWidgets.Tree.Branch("Citrus", () =>
    {
        ImGuiWidgets.Tree.Leaf(() => ImGui.Button("Lemon"));
    });

    ImGuiWidgets.Tree.Leaf(() => ImGui.Button("Apple"));
});

Design notes

Why the body is a callback. A collapsed branch has to skip its content, and a using scope cannot decline to run its own body. Deferring it means there is no open-state check to write and none to forget. It also means a collapsed branch never constructs the nested Tree at all, which is why it neither indents nor draws a spine over no children — no suppression flag needed.

Why branches are not handed their parent. Every member of Tree is private geometry, so a caller could read nothing useful from a handle, and the nesting is already lexical. The parent comes from a [ThreadStatic] stack of open trees instead. ImGui is an ambient-context API throughout and ImGui.Text does not ask which window either. The scopes keep the stack balanced even when a body throws.

Root rows. A branch with no enclosing branch draws no connector, because it has nothing above it to join to — which is what file explorers do. That also means no wrapper object and no InvalidOperationException for "no tree open".

Allocation. State overloads let the callback be static and capture nothing, so a large tree does not pay one closure per node per frame.

Compatibility

Purely additive. The Tree constructor, Child and TreeChild are untouched, so the scope form still compiles and still draws a spine at the outermost level, which a bare Branch does not. Tagged [minor].

Testing

Solution builds with 0 warnings under warnings-as-errors. ImGui.Widgets.Tests passes 283/283.

Not verified by running the demo: the drawing itself. Per this repo's own convention the draw paths are verified visually in ImGuiWidgetsDemo, and the Tree View section there now exercises branches, leaves, nesting and the state overloads — but I have not looked at the window, so the indent alignment and connector geometry want an eye before merge.

🤖 Generated with Claude Code

Tree drew connector lines around whatever was nested inside it and nothing
else, so anyone wanting a tree that actually collapses had to pair it with
ImGui.TreeNodeEx themselves. That pairing has a trap in it: Tree indents its
own contents, ImGui's tree push indents them again, and every level marches
further right than the lines are drawn. The fix is NoTreePushOnOpen on each
node, which is not discoverable from either API.

Branch and Leaf do that pairing correctly. A branch takes its body as a
callback and invokes it only while expanded, so there is no open-state check
to write and none to forget, and a collapsed branch never constructs the
nested Tree at all, which is why it neither indents nor draws a spine over no
children.

Branches find their parent from a thread-static stack of open trees rather
than being handed one. The handle would carry no information: every member of
Tree is private geometry, so a caller could read nothing from it, and the
nesting is already lexical. ImGui is an ambient-context API throughout, and
ImGui.Text does not ask which window either. A branch with no enclosing branch
is a root row, and a root row has nothing above it to join to, so it draws no
connector.

State overloads let callers pass a static lambda and allocate no closure per
node per frame, which matters once a tree is large.

Purely additive. The Tree constructor, Child and TreeChild are untouched, so
the scope form still works and still draws a spine at the outermost level.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread ImGui.Widgets/Tree.cs
_ = openTrees!.Pop();
};

(openTrees ??= new Stack<Tree>()).Push(this);
matt-edmondson and others added 2 commits September 16, 2026 17:25
Every one of the 26 ImGuiAppDemo UI tests was failing in SetUp with
MissingManifestResourceException. The resx embeds under a manifest name derived
from RootNamespace, which ktsu.Sdk computes from AuthorsNamespace and
ProjectNamespace, while Resources.Designer.cs carries the name as a string baked
in when it was last generated. The two had drifted: the assembly holds
ktsu.ImGuiAppDemo.Properties.Resources.resources and the designer was asking for
ktsu.examples.ImGuiAppDemo.Properties.Resources.

Nothing catches that at build time, because both halves are individually valid
and only meet at run time on the first lookup.

The designer now asks for the name the resource is actually embedded under, and
LogicalName pins that name in the project file so a future RootNamespace change
cannot desynchronise them again. Aligning on the MSBuild-derived name rather
than the stale one also means regenerating the designer reproduces a matching
string instead of breaking it a second time.

Pre-existing on main rather than anything this branch introduced, and reproduced
on Windows as well as the Ubuntu runner, so it was never platform specific. The
macOS job was not running this project.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
New-code coverage came back at 46.5 against a threshold of 80, because the
branch and leaf paths arrived with no tests and the demo drew them collapsed, so
their bodies never ran under test either.

Nine tests in TreeTests drive them through the existing widget harness rather
than leaving them to visual inspection: a collapsed branch does not run its body
and an expanded one does, DefaultOpen reaches the underlying node, a branch with
nothing enclosing it still draws, each level indents past the last, the
connectors are actually drawn, and both state overloads hand their state through.

The last of them is the one worth having. A branch finds its parent on a stack,
so a body that throws must still unwind it, or everything drawn afterwards is
parented to a tree that has gone. That is invisible until it happens and cheap
to assert now.

The demo drew its branches collapsed, which showed the feature off poorly and
left its callbacks unexecuted. They are DefaultOpen now, and the demo test
asserts the nodes inside them.

Opening the demo's state example needed flags and state together, which the API
could not express. That gap would have met the first person who wanted both, so
Branch<TState> now has a flags overload to match the non-generic one, and the
three-argument form delegates to it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant