Files
ClaudeDo/docs/explore-notes/list-virtualization-spike.md
mika kuns 2a8991496f docs(explore-notes): report Phase 2a virtualization+drag gate spike
Headless Avalonia ListBox+VirtualizingStackPanel spike answers all five
questions from the Phase 2a gate task: containers stay bounded at 1000
items, the existing InputHitTest ghost-drag model survives recycling,
variable row heights don't drift the Extent (only naive index*height
math would), auto-scroll via Offset mutation is mechanically feasible,
and mixed header/task rows template correctly via implicit DataTemplates.
Recommendation: proceed to Phase 2b. Spike code itself is throwaway and
not checked in.
2026-08-10 13:39:03 +02:00

6.9 KiB
Raw Permalink Blame History

List virtualization spike (Phase 2a gate)

Explore-note — verify before trusting. Distilled map of a subsystem, not authoritative. Last verified against commit 6a2a19c (2026-08-10). Drift check: git log --oneline 6a2a19c..HEAD -- src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml.cs src/ClaudeDo.Ui/Views/Controls/TaskDragController.cs Stable structure only (no line numbers). See docs/explore-notes/README.md.

Gate task for ui-reaktivitaet-und-listen-performance-design Phase 2, Risk 1. Answers the five open questions before any rework of the real TasksIslandView/TasksIslandViewModel. No production code changed for this spike.

Method

A throwaway Avalonia-headless xUnit test (Avalonia.Headless + Avalonia.Themes.Fluent, temporarily added to ClaudeDo.Ui.Tests, removed again after this report was written — not committed) built a plain ListBox with an explicit VirtualizingStackPanel and 1000 mixed items (≈14% "complex" two-line/badge rows, mirroring the 68px/90110px split from the design doc), then drove it programmatically: forced layout/render passes, scrolled the ScrollViewer, called the same TopLevel.InputHitTest the production drag code uses, and counted realized ListBoxItem containers via the visual tree. No screenshots — every number below came from an actual headless run of that harness on 2026-08-10.

One harness-only wrinkle: InputHitTest walks the compositor's rendered scene, not just the layout tree, so the test had to call window.CaptureRenderedFrame() after each layout-affecting change before hit-testing. A real windowed app renders every frame continuously, so this isn't a production concern — noted here only so the method is reproducible.

Q1 — Does a virtualized ListBox actually bound the number of realized containers at 1000 items?

Geht. Realized ListBoxItem count stayed at 2223 across the whole scroll range (top, 25%, 50%, 75%, bottom) with 1000 source items in the list:

realized containers @ top:        22
realized containers @ 25% scroll: 22
realized containers @ 50% scroll: 22
realized containers @ 75% scroll: 23
realized containers @ 100% scroll: 22

Matches the design doc's estimate (~19 visible + overscan ≈ 2225) almost exactly. The ListBox/VirtualizingStackPanel combination genuinely recycles containers instead of building one per item.

Q2 — Does the existing ghost-drag InputHitTest model survive container recycling?

Geht. Scrolled from top to 60% (forcing recycling), then called TopLevel.InputHitTest at the same screen point both times — exactly what RowButtonAt/ListItemUnder in TasksIslandView.axaml.cs do live during a drag:

hit-test @ top, screen point (450,600):        Task[8]
hit-test @ 60% scroll, same screen point:      Task[577]
expected DataContext at that point (geometry): Task[577]

The hit-test result matches the geometrically-correct row after recycling, and correctly changed (it did not keep returning the stale top-of-list row). The production pattern — topLevel.InputHitTest(pt) then walk ancestors to the row Button/ListBoxItem — is recycling-safe: it's a live geometric query against whatever is currently realized, with no per-row state to go stale.

Q3 — Variable row heights (68px vs 90110px): does the scrollbar/Extent estimate drift?

Geht, mit einer Einschränkung. Measured actual template heights in this harness: header 57px, simple task row 53px, complex (two-line + badges) row 90px. Hand-summing all 1000 rows by their real measured height gives 58275px; VirtualizingStackPanel's reported Extent.Height was 58228px — 0.1% error. Avalonia's own extent bookkeeping for variable-height virtualized rows is not the "gross estimate that drifts" risk the design doc worried about.

The real risk is a hand-rolled pixel↔index conversion. Scrolling to index * 68px (a naive "assume every row is 68px" offset) while targeting logical row #700 actually landed on row #790 — a 90-row drift, because ~14% of the preceding rows were taller. Anything that computes a scroll offset from index * fixedRowHeight (a natural shortcut for a "scroll to selected row" or a custom auto-scroll speed curve) will misfire. The existing ScrollSelectedIntoView in TasksIslandView.axaml.cs already avoids this trap — it finds the realized control by DataContext and calls BringIntoView(), not index-times-height math. Phase 2b must keep using ListBox.ScrollIntoView(item) / Control.BringIntoView(), never index arithmetic, for any "jump to row" behavior.

Q4 — Is auto-scroll-while-dragging feasible with the existing central-pointer-handler model?

Geht. Auto-scroll doesn't exist today and has to be built, but the mechanism it needs — the same UI-thread pointer-moved handler (or a DispatcherTimer it starts) mutating ScrollViewer.Offset directly while a drag is in progress — was exercised directly: nudging Offset by 400px in one synchronous step kept the realized-container count constant (22 before, 22 after) and an immediate subsequent InputHitTest at the same screen point resolved correctly to the new row under the cursor (Task[388]), with no async delay or stale state. Since TasksIslandView's drag handlers already run on the UI thread via tunnelled pointer events, a DispatcherTimer (or a direct Offset nudge inside OnPointerMovedDrag when the cursor is near the list's top/bottom edge) has no threading obstacle. This still needs to be built in Phase 2b; this spike only confirms the approach isn't blocked by virtualization/recycling.

Q5 — Mixed item types (header rows vs. task rows) via a template selector, under virtualization

Geht. Used ListBox.DataTemplates with two FuncDataTemplate<T> entries keyed by type (HeaderRow / TaskRow) instead of an explicit IDataTemplate.Match selector — Avalonia's implicit per-type template lookup is the idiomatic equivalent and needs no selector class. Both types showed up among the realized containers throughout scrolling (Q5 distinct realized DataContext types: HeaderRow, TaskRow), confirming the design's flat Rows (HeaderRow | TaskRowViewModel) collection will virtualize and template correctly.

Recommendation for Phase 2b

Geht — proceed, with two carry-overs into the implementation:

  1. Any "scroll to row" logic (selection scroll, auto-scroll speed/targeting) must go through ScrollIntoView/BringIntoView against the realized control, never index * rowHeight math (Q3).
  2. Auto-scroll is new work, not a retrofit risk — build it as an Offset nudge from the existing UI-thread drag handlers (Q4).

No evidence surfaced against the Phase 2 design as written. The ITaskListFilter risk (#3 in the design doc) is unrelated to virtualization and out of this spike's scope.