Skip to content

Add a property grid to the widget library - #381

Merged
matt-edmondson merged 5 commits into
mainfrom
claude/peaceful-johnson-nfj5cb
Sep 9, 2026
Merged

Add a property grid to the widget library#381
matt-edmondson merged 5 commits into
mainfrom
claude/peaceful-johnson-nfj5cb

Conversation

@matt-edmondson

@matt-edmondson matt-edmondson commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Closes #379.

ImGuiWidgets.PropertyGrid lays out one labelled editor per property in a two-column resizable table. It is immediate mode like the rest of the library: it holds no model, each row edits a variable by reference and returns whether it changed, and Changed accumulates those answers so the whole grid can be tested once.

using (ImGuiWidgets.PropertyGrid grid = new("Settings", options))
{
    using (grid.Section("Basics"))
    {
        grid.Value("Visible", ref visible);            // bool
        grid.Value("Quantity", ref quantity, 0, 100);  // int, clamped
        grid.Value("Name", ref name);                  // string
        grid.Enum("Mode", ref mode);
    }

    grid.Value("Offset", ref offset);   // Vector2/Vector3, float or double
    grid.Value("Tint", ref tint);       // ktsu.Semantics.Color.Color
    grid.FilePath("Config", ref configPath);
    grid.DirectoryPath("Output", ref outputFolder);
    grid.ImagePath("Icon", ref iconPath);   // with a thumbnail beneath it
    grid.List("Tags", tags);
}

What is covered

Everything the issue asked for. One overloaded Value row covers bool, int, long, float, double, string, Vector2/Vector3 and their new DoubleVector2/DoubleVector3 counterparts, and the semantic Color; Enum, the three path rows and List cover the rest. The numeric rows have clamped overloads taking a range.

A List row draws one editor per element, a + to append and an x to remove. Any row method is an element editor, so there is an overload per supported element type, plus FilePathList/DirectoryPathList/ImagePathList for the three whose element type is also a string, and a generic overload taking an editor and a factory for anything else.

A collapsed section holds its own rows back

Section returns a SectionScope disposable, so a section is a using block like every other scope in this library — there is nothing to remember to call, and forgetting an EndSection no longer unbalances ImGui's tree stack.

The scope does more than pair the call: while the section is collapsed it raises the grid's suppression depth and every row draws nothing, so the rows inside are written exactly as they are anywhere else, with no test around them. That is the same answer the grid already gave for a table that never opened, so there is one rule rather than two, and a depth rather than a flag is what lets a section nested inside a collapsed one stay balanced on its own. SectionScope.IsOpen remains for the one case suppression cannot cover: skipping work that costs something to prepare before a row can be called.

Paths and thumbnails are delegated, not owned

ktsu.ImGui.Widgets has no business knowing how the host picks files, and reaching for Hexa's own dialogs would make the deferred-drawing pump a hard requirement of every property grid. So a browse button raises PropertyGridOptions.OnBrowse with a PropertyPathRequest instead. A dialog outlives the frame that opened it, so the request carries no reference to the value: Complete records the answer under the row's ImGui id — safe from any thread and any later frame — and the row adopts it on its next draw. Completing with null leaves the value alone, which is what a cancelled dialog should do. Without a handler the browse button is disabled and paths can still be typed.

Texture upload lives in ktsu.ImGui.App, which this library deliberately does not reference, so an image row asks PropertyGridOptions.ThumbnailResolver for a texture id and draws an empty preview frame when there is none, keeping its height stable once a picture appears.

Other options: ReadOnly, LabelColumnWeight/LabelColumnWidth, ListsStartExpanded, ThumbnailSize, FloatFormat/DoubleFormat.

Demo and tests

  • examples/ImGuiWidgetsDemo gains a Property Grid section showing every row type, with read-only and label-width toggles, browsing wired to Hexa's file dialogs and thumbnails wired to ImGuiApp.GetOrLoadTexture.
  • tests/ImGui.Widgets.UITests/PropertyGridTests.cs — 23 headless tests covering every row type, change reporting, read-only, the browse round trip (including cancellation and the kind each row asks for), thumbnails with and without a texture, list add/remove/edit, and sections: every row type surviving a collapsed one, nesting, IsOpen, and rows after a section not being held back by it.
  • tests/ImGuiWidgetsDemo.UITests drives the new demo section end to end.

The suites caught a real bug: every browse button shared one ImGui id, as did the add buttons of two lists in one grid, because a row draws into a single id stack. Each button's id now carries its row's label.

Verification

  • dotnet build ImGui.sln -c Release — clean.
  • tests/ImGui.Widgets.UITests — 340/340 pass (23 of them this widget's).
  • tests/ImGui.Widgets.Tests — 256/256 pass.
  • tests/ImGuiWidgetsDemo.UITests — the new test passes; the 12 failures in that suite are pre-existing in this container (ktsu.png is an unfetched Git LFS pointer, so GetOrLoadTexture throws) and are identical with and without this change.

Docs updated: ImGui.Widgets/README.md (feature entry plus a Property Grid section), the root README.md widget lists, and CLAUDE.md (widget list, key files, and a Property grid section covering the section suppression, the browse handshake, the resolver contract, the per-row button ids and the list probe names).

🤖 Generated with Claude Code

https://claude.ai/code/session_01GTgzo1j271VvvXUNyLi3sL

Closes #379.

ImGuiWidgets.PropertyGrid lays out one labelled editor per property in a
two-column resizable table, opened in a using statement and holding no
model of its own: each row edits a variable by reference and reports
whether it changed, and the grid accumulates those answers in Changed.

One overloaded Value row covers bool, int, long, float, double, string,
Vector2/Vector3 and their new DoubleVector2/DoubleVector3 counterparts,
and the semantic Color. Enum, FilePath, DirectoryPath and ImagePath cover
the rest, Section draws collapsible groups, and List draws one editor per
element with append and remove buttons -- with an overload per supported
element type, since any row method is an element editor.

Paths and thumbnails are delegated rather than owned: the library knows
nothing about file dialogs or texture upload, and taking on Hexa's dialogs
would make the deferred-drawing pump a hard requirement. A browse button
raises PropertyGridOptions.OnBrowse with a request the host completes
whenever its dialog closes -- from any thread and any later frame -- which
the row adopts on its next draw; an image row asks ThumbnailResolver for a
texture and draws an empty preview frame when there is none.

Also adds a Property Grid section to ImGuiWidgetsDemo, wired to Hexa's file
dialogs and to ImGuiApp's texture cache, an isolation suite covering every
row type, and a demo-suite test driving the section end to end.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTgzo1j271VvvXUNyLi3sL
Comment thread ImGui.Widgets/PropertyGrid.cs Fixed
Comment thread ImGui.Widgets/PropertyGrid.cs Fixed
matt-edmondson and others added 4 commits September 9, 2026 05:04
…licitly

Two findings from the code quality review, both in the pending-browse
table.

The tick a pending result is stamped with was incremented directly from
the grid's constructor, which writes a static field from an instance
member and, more to the point, is not atomic. It is read from whatever
thread answers a browse, so it is now advanced through Interlocked and
read back the same way, with the reader taking one value and passing it
into the sweep rather than each comparison reading the field again.

The sweep filtered its copied key array with an if inside the loop; the
condition now sits on the sequence where the rule wants it. The copy is
still what keeps the removal safe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTgzo1j271VvvXUNyLi3sL
Seven non-blocking issues from the Sonar analysis, all in the new files.

The Enum row sat in the middle of the Value overloads, splitting the group
in two; it now follows them, so the overloads are adjacent. The rest are
the MSTest assertions the analyzer prefers for what each one is actually
checking: element counts through HasCount, a sequence through
AreSequenceEqual, and a lower bound through IsGreaterThan, each of which
reports the real value rather than "expected true".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTgzo1j271VvvXUNyLi3sL
Section returned a bool and left EndSection to the caller, which is the
one thing in the grid that had to be remembered rather than expressed --
and forgetting it does not fail visibly, it unbalances ImGui's tree stack.

It now returns a SectionScope disposable, so a section is a using block
like every other scope in this library. The scope does more than pair the
call: while the section is collapsed it raises the grid's suppression
depth and every row draws nothing, so the rows inside are written exactly
as they are anywhere else, with no test around them. That is the same
answer the grid already gave for a table that never opened, so there is
one rule rather than two. A depth rather than a flag is what lets a
section nested inside a collapsed one stay balanced on its own.

SectionScope.IsOpen remains for the one case the suppression cannot cover:
skipping work that costs something to prepare before a row can be called.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTgzo1j271VvvXUNyLi3sL
The two assertions on SectionScope.IsOpen compared a nullable bool against
a literal, where IsTrue and IsFalse say the same thing more directly and
report what the value actually was. Null still fails either of them, which
is the answer wanted: it means the section body never ran at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTgzo1j271VvvXUNyLi3sL
@sonarqubecloud

sonarqubecloud Bot commented Sep 9, 2026

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.

Add a property grid to the widget library

1 participant