Gum gum-forms-itemscontrol
Reference guide for ItemsControl and ListBox — the Items/ListBoxItems relationship, templates, InnerPanel sync, and gotchas. Load this when working on ItemsControl, ListBox, ListBoxItem, VisualTemplate, FrameworkElementTemplate, Items collection behavior, ListBoxItems desync, or adding/removing items from a list box.
git clone https://github.com/vchelaru/Gum
T=$(mktemp -d) && git clone --depth=1 https://github.com/vchelaru/Gum "$T" && mkdir -p ~/.claude/skills && cp -r "$T/.claude/skills/gum-forms-itemscontrol" ~/.claude/skills/vchelaru-gum-gum-forms-itemscontrol && rm -rf "$T"
.claude/skills/gum-forms-itemscontrol/SKILL.mdItemsControl and ListBox Reference
Key Files
| File | Purpose |
|---|---|
| Base class: Items property, template resolution, InnerPanel sync |
| Adds ListBoxItems tracking, selection, and ListBoxItem creation |
| Individual row control; holds IsSelected, IsHighlighted, events |
| Creates instances (visual-first) |
| Creates instances (forms-first) |
The Two Collections
Items and ListBoxItems are separate and can get out of sync.
(Items
, defaultIList
) — the logical data collection. Can hold anything: strings, view models,ObservableCollection<object>
instances, or anyListBoxItem
/FrameworkElement
.GraphicalUiElement
(ListBoxItems
) — the visual row controls actually shown. WrapsReadOnlyCollection<ListBoxItem>
(aListBoxItemsInternal
).List<ListBoxItem>
In normal usage (adding data objects to
Items) they stay in sync. They diverge in several cases — see Desync Gotchas below.
Data Flow: Items → InnerPanel → ListBoxItems
Adding to
Items triggers a two-stage pipeline:
— responds toHandleItemsCollectionChanged
. Creates or locates a visual and inserts it intoItems
.InnerPanel.Children
— responds toHandleInnerPanelCollectionChanged
. CallsInnerPanel.Children
.HandleCollectionNewItemCreated(frameworkElement, index)
(ListBox override) — if the item is aHandleCollectionNewItemCreated
, inserts it intoListBoxItem
and callsListBoxItemsInternal
.AssignListBoxEvents
HandleCollectionNewItemCreated is NOT called directly from step 1. It is only triggered by InnerPanel firing its own CollectionChanged. This indirection is intentional.
What Gets Created Per Item Type
HandleItemsCollectionChanged dispatches based on what was added to Items:
Item type added to | What happens |
|---|---|
| Its is inserted into InnerPanel directly — no new wrapper created |
| Inserted into InnerPanel directly — no wrapper |
Any other data object AND is set | is called; result inserted |
Any other data object, no | is called |
ListBox overrides
with additional logic:CreateNewItemFrameworkElement
| Item type | ListBox behavior |
|---|---|
| Used as-is — no template, no wrapping |
| Anything else | Calls (uses or ), then wraps result in a via . and are called on the result. |
Templates
There are two template types with different roles:
— produces a VisualTemplate
GraphicalUiElement (visual-first).
- Constructed with a
(must haveType
or no-arg constructor),(bool, bool)
,Func<GraphicalUiElement>
, orFunc<object, GraphicalUiElement>
.Func<object, bool, GraphicalUiElement> - Used by
. When constructed from aCreateNewVisual
, calls it withType
—(true, false)
prevents the visual from creating its own Forms object, since the ListBox will wrap it.createFormsInternally:false - Set on
. Changing it clears and rebuilds all visuals.ItemsControl.VisualTemplate
— produces a FrameworkElementTemplate
FrameworkElement (forms-first).
- Constructed with a
orType
.Func<FrameworkElement> - Used by
. For ListBox, the result must be aCreateNewItemFrameworkElement
subclass or an exception is thrown.ListBoxItem - Set on
.ItemsControl.FrameworkElementTemplate
Global fallback — if neither template is set,
DefaultFormsTemplates[typeof(ListBoxItem)] is used (set during app initialization). This is the normal path for default apps.
Setting a template clears and rebuilds all existing items — both
VisualTemplate and FrameworkElementTemplate setters call ClearVisualsInternal() and replay the Items collection.
Desync Gotchas
1. Adding a non-ListBoxItem FrameworkElement to Items
When a
Button, CheckBox, or other FrameworkElement is added to Items, its Visual is inserted into InnerPanel. HandleInnerPanelCollectionChanged fires, but asGue.FormsControlAsObject is the Button (not a ListBoxItem), so HandleCollectionNewItemCreated is called with a Button. ListBox's override only inserts into ListBoxItemsInternal if the item is ListBoxItem, so the Button is silently skipped. Items.Count increases but ListBoxItems.Count does not.
2. Adding directly to InnerPanel.Children
HandleInnerPanelCollectionChanged fires and can populate ListBoxItemsInternal if the child's FormsControlAsObject is a ListBoxItem. But Items is never updated — it stays at 0 (or whatever it was). This is the case when a ListBox's visual is constructed by the Gum tool with pre-filled children.
3. Gum tool pre-filled ListBox (ReactToVisualChanged recovery)
If a ListBox Visual arrives with children already in InnerPanel and
Items.Count == 0, ReactToVisualChanged in ListBox iterates InnerPanel.Children, adds ListBoxItem instances to both Items and ListBoxItemsInternal, and calls AssignListBoxEvents. This recovery only runs once at construction; it does not stay in sync afterward.
4. Index alignment assumption
HandleItemSelected resolves the data object via Items[ListBoxItemsInternal.IndexOf(listBoxItem)]. If Items and ListBoxItems have drifted (any of the above cases), selection silently fails or selects the wrong item — the check clickedIndex >= Items.Count causes an early return.
5. AssignListBoxEvents idempotency
ListBoxItem.AssignListBoxEvents is guarded by hasHadListBoxEventsAssigned. A ListBoxItem that bypasses normal item creation (e.g., added to InnerPanel directly without going through Items) may or may not have its events assigned. If events are missing, the item renders but clicking it produces no selection change.
Selection
SelectedItems is an ObservableCollection<object> (not replaceable, only modified). SelectedObject and SelectedIndex are convenience properties that read/write the first entry in SelectedItems.
SyncIsSelectedFromSelectedItems walks ListBoxItemsInternal and reconciles IsSelected on each item. It runs whenever SelectedItems changes or SelectedObject/SelectedIndex are set.
SelectionMode controls click behavior: Single (default), Multiple (each click toggles), Extended (Ctrl/Shift modifier keys). Gamepad/keyboard input always uses single-selection behavior regardless of mode.
DisplayMemberPath
When set,
DisplayMemberPath causes listBoxItem.UpdateToObject(property_value_as_string) instead of UpdateToObject(the_object_itself). This applies both on initial creation and when DisplayMemberPath is changed after items are already loaded.