Skip to content

Add a virtualized table widget [minor] - #435

Merged
matt-edmondson merged 1 commit into
mainfrom
claude/imguiapp-411-virtual-table
Sep 23, 2026
Merged

matt-edmondson merged 1 commit into
mainfrom
claude/imguiapp-411-virtual-table

Conversation

@matt-edmondson

Copy link
Copy Markdown
Contributor

Fixes #411

ImGui.Widgets had Grid, TabPanel, PropertyGrid and Tree, none of which virtualize. A consumer with tens of thousands of rows had to drop to ImGuiListClipper directly — and lost the probe marking that keeps a widget UI-testable on the way down.

ImGuiWidgets.VirtualTable("catalogue", objects.Count, columns, row =>
{
    ImGui.TextUnformatted(objects[row].Name);
    ImGui.TableNextColumn();
    ImGui.TextUnformatted(objects[row].Epoch);
}, options);

drawRow is called only for visible rows, in ascending order, and is given the absolute index into the caller's data.

The three decisions the issue settled, implemented as specified

Absolute row indices in probe names. Each drawn row is marked <label>/[<absoluteIndex>], following the Tags/[0] convention PropertyGridLists already uses. The issue is right that this is the one call that cannot be changed later, so it is asserted from both directions rather than just written down: RowProbeNamesUseTheAbsoluteIndex scrolls to row 15,000 and checks it is addressable as catalogue/[15000] and that nothing was marked as [0], which is what a view-relative name would produce.

The widget never sorts. OnSortChanged(column, ascending) reports the change and stops. DoesNotReorderRowsItself pins that, because "it sorts nothing" is a decision rather than an omission and should fail loudly if someone adds sorting later.

Uniform row height, documented rather than discovered. RowHeight carries the constraint in its own XML docs, along with why variable height is a different widget rather than a flag on this one.

Two things worth reviewing rather than skimming

Headings are submitted one at a time, not through TableHeadersRow. This is the only place I departed from the obvious implementation. TableHeadersRow submits every heading and returns, so there is no moment at which any one of them is the current item and nothing can be marked. On a sortable table the headings are the only control there is — a heading a test cannot click is a table a test cannot sort. I found this by writing the sort tests first and watching them fail with No item matching 'Index' has been marked; submitting the headings individually is ImGui's own supported alternative and costs one loop.

ScrollToRow anchors on the row, not on arithmetic. My first version computed SetScrollY(row * rowHeight). That is wrong whenever the real row pitch differs from RowHeight — which it does as soon as cell padding is in play, and always when the caller leaves RowHeight unset. It now calls clipper.IncludeItemByIndex to force the row to be drawn and SetScrollHereY on the row itself, so no height arithmetic exists to be wrong. ScrollToRow is one-shot (cleared by the widget) and lands on the same frame rather than the one after.

Selection is a full-width Selectable with SpanAllColumns | AllowOverlap submitted under the caller's cells. It is doing three jobs — the highlight, the hit target, and the one real ImGui item that knows the row's true rectangle — which is why the row's mark comes from it rather than from a rectangle the widget computed. options.SelectedRow is a settable property rather than the ref int the issue's prose describes, since the signature it also specifies takes no ref parameter; options are already passed by reference, so the read-write behaviour is the same. Flagging it as the one place the issue's two descriptions disagree.

Tests

tests/ImGui.Widgets.UITests/VirtualTableTests.cs, 16 cases, one class driving the widget with nothing else on screen per the suite's convention. Full widget suites green: 382 UI tests and 283 unit tests, 0 failed.

Confirmed the tests depend on the change by breaking it three ways and re-running:

mutation result
clipper loop replaced with a loop over all 30,000 rows 4 fail — DrawsABoundedNumberOfRowsWhateverTheRowCount, OnlyDrawnRowsAreProbeAddressable, ScrollToRow_MovesTheViewportRatherThanJustDrawingTheRow, RowProbeNamesUseTheAbsoluteIndex
rows marked and selected by visible index instead of absolute 3 fail — both RowProbeNames… assertions and SelectsTheAbsoluteRowThatWasClicked
IncludeItemByIndex + SetScrollHereY removed 4 fail — every ScrollToRow case

The header-marking change earned its place the same way: before it, the two sort tests failed outright.

DrawsABoundedNumberOfRowsWhateverTheRowCount is the one that matters — 30,000 rows, and the assertion is that fewer than 200 were drawn. Without the clipper it draws all 30,000.

Demo

examples/ImGuiWidgetsDemo/VirtualTableDemo.cs, a 30,000-row catalogue that reports how many rows were actually drawn last frame, plus a "go to row" control exercising ScrollToRow. Its rows are synthesized from the index — there is no list of 30,000 anything behind it, which is only affordable because of the widget.

It is its own class rather than another section in ImGuiWidgetsDemo, because adding it there tripped CA1506 (class coupling 99 against a limit of 96). "Virtual Table" is added to AdvancedDemoSections in the demo suite.

Not covered, and one thing that isn't mine

SortMulti is not supported; only the primary sort column is reported, and the XML docs say so rather than leaving a caller to find out. There is no test that a row taller than RowHeight misbehaves — that is the documented constraint, and asserting the failure mode would pin behaviour the widget does not promise.

12 of 29 ImGuiWidgetsDemo.UITests fail in my container, and they fail identically on an unmodified tree — I stashed everything and re-ran to check. The cause is environmental, not a repo defect: examples/ImGuiWidgetsDemo/ktsu.png is still a Git LFS pointer here (git lfs is not installed), so the demo's texture load throws Unrecognised image format; the file starts with 76 65 72 73 — that being vers of version https://git-lfs.... They should pass on a runner with LFS. Worth knowing because AdvancedDemos_ListsEverySection is among them, so my addition to its section list is unverified by a passing run and is the one part of this change CI will be the first to actually exercise.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JYniXikaVdTF8Gwdwj7uns


Generated by Claude Code

ImGui.Widgets had Grid, TabPanel, PropertyGrid and Tree, none of which
virtualize, so a consumer with tens of thousands of rows had to drop to
ImGuiListClipper directly — and lost the probe marking that keeps a widget
UI-testable on the way down.

VirtualTable is backed by ImGuiListClipper, so the draw delegate is called
only for visible rows and is given the absolute row index. Uniform row
height is the clipper's assumption and stays a documented v1 constraint;
sorting is reported through OnSortChanged and never applied, since the
widget does not own the data.

Marking follows the decisions the issue settled: the table is marked under
its label whether or not any row is visible, and each drawn row under
<label>/[<absoluteIndex>], following the PropertyGrid list convention. The
index is absolute so a probe name does not change with scroll position.
Column headings are submitted one at a time rather than through
TableHeadersRow, which submits them all and leaves nothing to mark — a
sortable table whose headings cannot be clicked is not testable.

Fixes #411

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

Copy link
Copy Markdown

@matt-edmondson
matt-edmondson merged commit aa96a87 into main Sep 23, 2026
14 checks passed
@matt-edmondson
matt-edmondson deleted the claude/imguiapp-411-virtual-table branch September 23, 2026 00:01
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.

No virtualized table widget, so a list of tens of thousands of rows has to reach past ktsu.ImGui.Widgets

2 participants