Skip to content

🏗️ Forge: ListBox Parity Integration#148

Merged
tedd merged 19 commits into
mainfrom
forge-listbox-parity-5850515012165907113
Mar 11, 2026
Merged

🏗️ Forge: ListBox Parity Integration#148
tedd merged 19 commits into
mainfrom
forge-listbox-parity-5850515012165907113

Conversation

@tedd

@tedd tedd commented Mar 10, 2026

Copy link
Copy Markdown
Owner

💡 Hypothesis: The current ListBox implementation manually handles rendering, measurement, and scrolling instead of adhering to the standard WPF ItemsControl templating architecture (ItemsPresenter within a ScrollViewer). This violates the target architectural isomorphism and prevents declarative customization.

🎯 Execution: Engineered ListBoxItem as a ContentControl supporting IsSelected and bubbling routed events. Refactored ListBox to abandon manual graphics operations and rely on a default ControlTemplate wrapping an ItemsPresenter inside a ScrollViewer. Synchronized Selector.SelectedIndex changes with the generated container logic natively via routed events.

📊 Functional Impact: Fully restores XAML and WPF architectural parity for list selection workflows. Developers can now utilize standard ControlTemplate behavior, while visual styling falls back robustly onto dependency properties.

🔬 Verification Protocol:

  1. Execute dotnet test src/Tedd.TUI.Tests to verify ListBox layout bounds correctly incorporate ScrollViewer padding and scrollbars.
  2. Ensure selection modifications trigger ApplyTemplate lifecycle validation and correct property invalidation.

PR created automatically by Jules for task 5850515012165907113 started by @tedd

- Created `ListBoxItem` inheriting from `ContentControl`.
- Added `IsSelected` dependency property and `Selected`/`Unselected` routed events to `ListBoxItem`.
- Replaced `ListBox` manual rendering and layout (`MeasureOverride`, `ArrangeOverride`, `Render`) with a `ControlTemplate` utilizing `ScrollViewer` and `ItemsPresenter`.
- Configured `ListBox` to act as an `ItemsControl` generator by overriding `GetContainerForItemOverride` and `IsItemItsOwnContainerOverride`.
- Synchronized `ListBox.SelectedIndex` with the visual state (`IsSelected`) of generated `ListBoxItem` containers.
- Updated layout and behavior test cases in `Tedd.TUI.Tests`.

Co-authored-by: tedd <493224+tedd@users.noreply.github.com>
@google-labs-jules

Copy link
Copy Markdown
Contributor

👋 Jules, reporting for duty! I'm here to lend a hand with this pull request.

When you start a review, I'll add a 👀 emoji to each comment to let you know I've read it. I'll focus on feedback directed at me and will do my best to stay out of conversations between you and other bots or reviewers to keep the noise down.

I'll push a commit with your requested changes shortly after. Please note there might be a delay between these steps, but rest assured I'm on the job!

For more direct control, you can switch me to Reactive Mode. When this mode is on, I will only act on comments where you specifically mention me with @jules. You can find this option in the Pull Request section of your global Jules UI settings. You can always switch back!

New to Jules? Learn more at jules.google/docs.


For security, I will only act on instructions from the user who triggered this task.

Copilot AI review requested due to automatic review settings March 10, 2026 04:38

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Refactors ListBox to follow the framework’s ItemsControl templating architecture (using an ItemsPresenter hosted in a ScrollViewer) and introduces a dedicated ListBoxItem container to support selection via routed events—aiming for closer WPF structural parity and better XAML customization.

Changes:

  • Added ListBoxItem as a ContentControl container with IsSelected and bubbling Selected/Unselected routed events.
  • Refactored ListBox to use a default ControlTemplate (ScrollViewer + ItemsPresenter) and container generation overrides instead of manual render/measure/scroll logic.
  • Updated ListBox measurement tests to account for templating and always-reserved scrollbar width.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 7 comments.

File Description
src/Tedd.TUI/ListBoxItem.cs New item container type with selection state + routed events, plus default template/content wiring.
src/Tedd.TUI/ListBox.cs Migrates ListBox to templated ItemsControl pattern and selection synchronization via container events.
src/Tedd.TUI.Tests/ListBoxMeasureTests.cs Updates measurement tests for templated behavior and scrollbar width reservation.
.jules/forge.md Documents the architectural refactor and rationale.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/Tedd.TUI/ListBox.cs
Comment on lines +181 to 196
// In Selector, OnSelectionChanged is fired when SelectedIndex/SelectedItem changes.
// We override it to sync IsSelected to the containers.
protected override void OnSelectionChanged()
{
base.OnSelectionChanged();

if (ItemsPanelRoot != null)
{
_scrollOffset = index - RenderSize.Height + 1;
for (int i = 0; i < ItemsPanelRoot.Children.Count; i++)
{
if (ItemsPanelRoot.Children[i] is ListBoxItem lbi)
{
lbi.IsSelected = (i == SelectedIndex);
}
}
}

Copilot AI Mar 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keyboard navigation changes SelectedIndex but never moves focus to the newly selected ListBoxItem. If focus is currently on a ListBoxItem (e.g., after a mouse click), arrow-key navigation will leave focus on the old item, so the new selection won’t be treated as “focused selected” by ListBoxItem.UpdateVisualState. Consider updating focus along with selection (e.g., focus the container at SelectedIndex when selection changes due to keyboard), or otherwise introducing a notion of focus-within on ListBox so items can style correctly without relying on per-item focus.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot open a new pull request to apply changes based on this feedback

Comment thread src/Tedd.TUI/ListBox.cs
Comment on lines +30 to +43
// Listen to SelectedEvent from children to update selection
AddHandler(ListBoxItem.SelectedEvent, new RoutedEventHandler(OnItemSelected));
}

private void OnScroll(object? sender, EventArgs e)
private void OnItemSelected(object? sender, RoutedEventArgs e)
{
_scrollOffset = _scrollBar.Value;
Invalidate();
if (e.OriginalSource is ListBoxItem item)
{
int index = ItemsPanelRoot?.Children.IndexOf(item) ?? -1;
if (index >= 0 && index != SelectedIndex)
{
SelectedIndex = index;
}
}

Copilot AI Mar 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new selection synchronization path (ListBoxItem.SelectedEvent bubbling to ListBox.OnItemSelected + container IsSelected updates in OnSelectionChanged) isn’t covered by existing tests. Consider adding tests that simulate selecting an item via ListBoxItem (e.g., setting IsSelected / raising the event) and assert SelectedIndex updates, and that changing SelectedIndex updates the generated containers’ IsSelected states.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot open a new pull request to apply changes based on this feedback

Comment thread src/Tedd.TUI/ListBoxItem.cs Outdated
Comment on lines +38 to +52
Template = new ControlTemplate(parent =>
{
var cp = new ContentPresenter();
cp.TemplatedParent = parent;

var contentBinding = new Binding("Content");
contentBinding.RelativeSource = RelativeSource.TemplatedParent;
cp.SetBinding(ContentPresenter.ContentProperty, contentBinding);

var templateBinding = new Binding("ContentTemplate");
templateBinding.RelativeSource = RelativeSource.TemplatedParent;
cp.SetBinding(ContentPresenter.ContentTemplateProperty, templateBinding);

return cp;
});

Copilot AI Mar 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ListBoxItem sets Background/Foreground in UpdateVisualState, but neither Control nor the current template paints the Background (Control.Render just renders TemplateRoot, and Background is not an inherited DP). This means selected items won’t actually show a background highlight (and the child TextBlock won’t pick up Background either). Consider rendering a background fill in ListBoxItem.Render (fill row with Background) or changing the default template to include an element that draws the background based on the templated parent’s Background/Foreground.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot open a new pull request to apply changes based on this feedback

Comment on lines +56 to +112
protected override void OnPropertyChanged(DependencyProperty dp)
{
base.OnPropertyChanged(dp);
if (dp == IsSelectedProperty || dp == IsFocusedProperty)
{
UpdateVisualState();
}

if (dp == IsSelectedProperty)
{
bool isSelected = (bool)GetValue(IsSelectedProperty);
if (isSelected)
{
RaiseEvent(new RoutedEventArgs(SelectedEvent, this));
}
else
{
RaiseEvent(new RoutedEventArgs(UnselectedEvent, this));
}
}
}

private void UpdateVisualState()
{
// Try to get colors from Parent ListBox if we are inside one.
// If not, use defaults.
var parentListBox = Parent as ListBox;

// Sometimes parent is not set yet, so search up the visual tree
if (parentListBox == null)
{
var curr = Parent;
while(curr != null && !(curr is ListBox))
{
curr = curr.Parent;
}
parentListBox = curr as ListBox;
}

ConsoleColor selectedBg = parentListBox?.SelectionBackground ?? ConsoleColor.White;
ConsoleColor selectedFg = parentListBox?.SelectionForeground ?? ConsoleColor.Black;

ConsoleColor focusedSelectedBg = parentListBox?.FocusedSelectionBackground ?? ConsoleColor.Blue;
ConsoleColor focusedSelectedFg = parentListBox?.FocusedSelectionForeground ?? ConsoleColor.White;

ConsoleColor normalBg = parentListBox?.Background ?? ConsoleColor.Black;
ConsoleColor normalFg = parentListBox?.Foreground ?? ConsoleColor.Gray;

bool showSelection = parentListBox?.ShowSelection ?? true;

if (IsSelected)
{
if (IsFocused || (parentListBox != null && parentListBox.IsFocused))
{
// Note: WPF ListBoxItem is focused when selected usually.
// Or if ListBox is focused.
Background = focusedSelectedBg;

Copilot AI Mar 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UpdateVisualState is only triggered when the ListBoxItem’s IsSelected/IsFocused changes. The visual state also depends on the parent ListBox’s focus (parentListBox.IsFocused) and ShowSelection, but ListBoxItem won’t recompute when the ListBox gains/loses focus, so colors can get “stuck” in the unfocused state. Consider recomputing on each render, or reacting to parent focus changes (e.g., override OnParentChanged and hook/unhook parent focus events or have ListBox invalidate/update its containers on focus changes).

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot open a new pull request to apply changes based on this feedback

Comment thread src/Tedd.TUI/ListBoxItem.cs Outdated
Comment on lines +34 to +52
public ListBoxItem()
{
Focusable = true;

Template = new ControlTemplate(parent =>
{
var cp = new ContentPresenter();
cp.TemplatedParent = parent;

var contentBinding = new Binding("Content");
contentBinding.RelativeSource = RelativeSource.TemplatedParent;
cp.SetBinding(ContentPresenter.ContentProperty, contentBinding);

var templateBinding = new Binding("ContentTemplate");
templateBinding.RelativeSource = RelativeSource.TemplatedParent;
cp.SetBinding(ContentPresenter.ContentTemplateProperty, templateBinding);

return cp;
});

Copilot AI Mar 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ListBoxItem duplicates ContentControl’s default template (ContentPresenter with bindings to Content/ContentTemplate). Since ContentControl already sets an identical Template in its constructor, this extra Template assignment is redundant and adds maintenance burden if the base template changes.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot open a new pull request to apply changes based on this feedback

Comment thread src/Tedd.TUI/ListBox.cs
Comment on lines +105 to 114
// Set content correctly based on ItemTemplate or fallback
if (ItemTemplate != null)
{
var pixelBg = Background ?? buffer.GetPixel(x + dx, y + i).Background;
buffer.SetPixel(x + dx, y + i, ' ', ConsoleColor.White, pixelBg);
lbi.ContentTemplate = ItemTemplate;
lbi.Content = item;
}

if (itemIndex < Items.Count)
else
{
bool isSelected = (itemIndex == SelectedIndex);
var bg = Background ?? buffer.GetPixel(x, y + i).Background;
var fg = Foreground;
if (isSelected)
{
if (IsFocused)
{
// Focused: selected item is blue
bg = FocusedSelectionBackground;
fg = FocusedSelectionForeground;
}
else if (ShowSelection)
{
// Not focused but ShowSelection enabled: inverted black/white
bg = SelectionBackground;
fg = SelectionForeground;
}
// else: ShowSelection is false and not focused, use default colors
}

if (ItemTemplate != null)
{
// Fill row with selection background first, then render template content on top
for (int dx = 0; dx < effectiveW; dx++)
{
buffer.SetPixel(x + dx, y + i, ' ', fg, bg);
}
var container = GetContainerForItemCore();
PrepareContainerForItemOverride(container, Items[itemIndex]);
container.Measure(new Size(effectiveW, 1));
container.Arrange(new Rect(0, 0, effectiveW, 1));
container.Render(buffer, x, y + i);
}
else
{
string content = GetItemText(Items[itemIndex]);
if (content.Length > effectiveW) content = content.Substring(0, effectiveW);

for (int dx = 0; dx < content.Length; dx++)
{
buffer.SetPixel(x + dx, y + i, content[dx], fg, bg);
}
// Fill rest of line with bg
for (int dx = content.Length; dx < effectiveW; dx++)
{
buffer.SetPixel(x + dx, y + i, ' ', fg, bg);
}
}
lbi.Content = GetItemText(item);
}

Copilot AI Mar 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When ItemTemplate is null, PrepareContainerForItemOverride sets ListBoxItem.Content = GetItemText(item). Because ListBox overrides IsItemItsOwnContainerOverride to not treat arbitrary UIElement items as their own containers, UIElement items (e.g., a Button) will be wrapped in a ListBoxItem and then coerced to string, so they won’t render/interact as UIElements. Consider using the raw item as Content when item is a UIElement (and only fall back to GetItemText for non-UIElement items).

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot open a new pull request to apply changes based on this feedback

Comment thread src/Tedd.TUI/ListBox.cs
Comment on lines +116 to +125
// Sync IsSelected
int index = Items.IndexOf(item);
if (index == SelectedIndex)
{
X = e.X - (RenderSize.Width - 1),
Y = e.Y,
Handled = false
};
_scrollBar.OnMouseDown(sbArgs);
e.Handled = true;
return;
}

// e.Y is already local relative to this control
int itemIndex = e.Y + _scrollOffset;

if (itemIndex >= 0 && itemIndex < Items.Count)
{
SelectedIndex = itemIndex;
// SelectionChanged is raised by base.SelectedIndex setter
lbi.IsSelected = true;
}
else
{
lbi.IsSelected = false;
}

Copilot AI Mar 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PrepareContainerForItemOverride calls Items.IndexOf(item) for every container to decide IsSelected. This makes container preparation O(n²) for large lists. Consider passing the item index into container generation (e.g., incrementing an index in ItemsPresenter.PopulatePanel) or otherwise avoiding repeated IndexOf calls when populating/refreshing containers.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot open a new pull request to apply changes based on this feedback

Copilot AI commented Mar 11, 2026

Copy link
Copy Markdown
Contributor

@tedd I've opened a new pull request, #162, to work on those changes. Once the pull request is ready, I'll request review from you.

Copilot AI commented Mar 11, 2026

Copy link
Copy Markdown
Contributor

@tedd I've opened a new pull request, #163, to work on those changes. Once the pull request is ready, I'll request review from you.

Copilot AI commented Mar 11, 2026

Copy link
Copy Markdown
Contributor

@tedd I've opened a new pull request, #164, to work on those changes. Once the pull request is ready, I'll request review from you.

Copilot AI commented Mar 11, 2026

Copy link
Copy Markdown
Contributor

@tedd I've opened a new pull request, #165, to work on those changes. Once the pull request is ready, I'll request review from you.

Copilot AI commented Mar 11, 2026

Copy link
Copy Markdown
Contributor

@tedd I've opened a new pull request, #166, to work on those changes. Once the pull request is ready, I'll request review from you.

Copilot AI commented Mar 11, 2026

Copy link
Copy Markdown
Contributor

@tedd I've opened a new pull request, #167, to work on those changes. Once the pull request is ready, I'll request review from you.

Copilot AI commented Mar 11, 2026

Copy link
Copy Markdown
Contributor

@tedd I've opened a new pull request, #168, to work on those changes. Once the pull request is ready, I'll request review from you.

Copilot AI and others added 4 commits March 11, 2026 13:08
…ForItemOverride

Co-authored-by: tedd <493224+tedd@users.noreply.github.com>
…ex and SelectedIndex→IsSelected paths

Co-authored-by: tedd <493224+tedd@users.noreply.github.com>
Co-authored-by: tedd <493224+tedd@users.noreply.github.com>
…focus

Co-authored-by: tedd <493224+tedd@users.noreply.github.com>
tedd added 2 commits March 11, 2026 14:18
[WIP] Address feedback from review on ListBox Parity Integration PR
Add tests for ListBox selection synchronization (item→SelectedIndex and SelectedIndex→IsSelected)
Copilot AI review requested due to automatic review settings March 11, 2026 13:18
tedd added 5 commits March 11, 2026 14:18
[WIP] WIP Address feedback from review on ListBox Parity Integration PR
[WIP] WIP Address feedback on ListBox Parity Integration PR
Remove redundant Template assignment in ListBoxItem
Fix ListBox coercing UIElement items to string when no ItemTemplate is set
…f4-a4e2-4f891462c6ec

[WIP] Address feedback on ListBox Parity Integration PR
@tedd
tedd merged commit a6a1187 into main Mar 11, 2026
3 checks passed

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/Tedd.TUI/ListBox.cs
Comment on lines +121 to 130
// Sync IsSelected
int index = Items.IndexOf(item);
if (index == SelectedIndex)
{
lbi.IsSelected = true;
}
else
{
lbi.IsSelected = false;
}

Copilot AI Mar 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PrepareContainerForItemOverride uses Items.IndexOf(item) to determine the container's index. This is O(n) per item during panel population (O(n^2) overall) and is also incorrect when Items contains duplicates (IndexOf returns the first occurrence, so selection can be applied to the wrong container). Consider syncing IsSelected based on the container's actual position in ItemsPanelRoot.Children (e.g., a post-population pass iterating children by index), instead of using Items.IndexOf here.

Suggested change
// Sync IsSelected
int index = Items.IndexOf(item);
if (index == SelectedIndex)
{
lbi.IsSelected = true;
}
else
{
lbi.IsSelected = false;
}
// Sync IsSelected without O(n) lookup or duplicate ambiguity
lbi.IsSelected = Equals(item, SelectedItem);

Copilot uses AI. Check for mistakes.
Comment on lines +56 to +62
RaiseEvent(new RoutedEventArgs(UnselectedEvent, this));
}
}
}

internal void UpdateVisualState()
{

Copilot AI Mar 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ListBoxItem visual state depends on parentListBox.IsFocused (focused selection colors), but UpdateVisualState() is only triggered when IsSelected or the item's own IsFocused changes. When focus moves to/from the ListBox itself (e.g., via Tab navigation), the selected item's visuals won't refresh. Consider having ListBox notify realized containers on its IsFocused changes (and possibly selection color DP changes), and making UpdateVisualState callable from ListBox (e.g., internal) or using bindings/triggers instead of caching colors into local Background/Foreground.

Copilot uses AI. Check for mistakes.
Comment thread src/Tedd.TUI/ListBox.cs
Comment on lines 5 to 6

public class ListBox : Selector

Copilot AI Mar 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file includes an unused using System.Reflection; import (at the top of the file). Please remove it to avoid analyzer warnings and keep imports clean.

Copilot uses AI. Check for mistakes.
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.

3 participants