Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .jules/forge.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,3 +92,6 @@
## 2026-03-09 - ControlTemplate Trigger Integration
**Observation:** The TUI framework lacked standard visual state infrastructure within `ControlTemplate`. There was an absence of WPF parity regarding `Trigger` mechanisms, which dynamically evaluate dependency properties and apply visual state updates (via `Setter`) without imperative event wiring.
**Strategic Action:** Integrated WPF visual state triggers by establishing the `TriggerBase`, `Trigger`, and `Setter` object model. Extended `ControlTemplate` with a `Triggers` collection. Augmented `Control.OnPropertyChanged` to intercept dependency property mutations, evaluate active trigger conditions (`EvaluateTriggers`), dynamically inject setter values when conditions are met, and automatically revert to original local/inherited property states (`DependencyProperty.UnsetValue`) when conditions fail.
## 2026-03-09 - ListBox WPF Isomorphism Parity
**Observation:** The TUI framework's `ListBox` component lacked architectural parity with standard WPF models. It directly implemented scrolling, item measurement, and rendering (`Render`, `MeasureOverride`, `ArrangeOverride`) instead of utilizing the `ItemsControl` templating paradigms (`ControlTemplate`, `ItemsPresenter`, `ScrollViewer`, and generated containers).
**Strategic Action:** Refactored `ListBox` to use a `ControlTemplate` wrapping an `ItemsPresenter` within a `ScrollViewer`. Created the `ListBoxItem` component (inheriting from `ContentControl`) to act as the generated item container. Implemented `Selected`/`Unselected` bubbling routed events on `ListBoxItem` to decouple selection synchronization, allowing `ListBox` to map UI interactions directly to its `SelectedIndex` property, establishing strict XAML structural isomorphism.
13 changes: 11 additions & 2 deletions src/Tedd.TUI.Tests/ListBoxMeasureTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ public void Measure_FixedHeight_UsesFixedHeight()
listBox.Height = 10;
listBox.Items.Add("Item 1");

listBox.ApplyTemplate();
listBox.Measure(new Size(100, 100));

Assert.Equal(10, listBox.DesiredSize.Height);
Expand All @@ -28,6 +29,7 @@ public void Measure_AutoHeight_UsesItemCount()
listBox.Items.Add("Item 2");
listBox.Items.Add("Item 3");

listBox.ApplyTemplate();
// Available size is large enough
listBox.Measure(new Size(100, 100));

Expand All @@ -45,6 +47,7 @@ public void Measure_AutoHeight_ConstrainedByAvailableSize()
listBox.Items.Add($"Item {i}");
}

listBox.ApplyTemplate();
// Available size is smaller (5)
listBox.Measure(new Size(100, 5));

Expand All @@ -59,6 +62,7 @@ public void Measure_FixedWidth_UsesFixedWidth()
listBox.Width = 20;
listBox.Items.Add("Long Item Name");

listBox.ApplyTemplate();
listBox.Measure(new Size(100, 100));

Assert.Equal(20, listBox.DesiredSize.Width);
Expand All @@ -72,11 +76,14 @@ public void Measure_AutoWidth_UsesMaxItemWidth()
listBox.Items.Add("Short");
listBox.Items.Add("Long Item Name"); // Length 14

listBox.ApplyTemplate();
// Available size is large enough
listBox.Measure(new Size(100, 100));

// Expect width to accommodate "Long Item Name" (14)
Assert.Equal(14, listBox.DesiredSize.Width);
// ScrollViewer is set to VerticalScrollBarVisibility = true.
// This adds 1 to the width for the ScrollBar.
// Expect width to accommodate "Long Item Name" (14) + ScrollBar (1) = 15
Assert.Equal(15, listBox.DesiredSize.Width);
}

[Fact]
Expand All @@ -90,12 +97,14 @@ public void Measure_AutoWidth_WithScrollbar()
listBox.Items.Add("Item"); // Length 4
}

listBox.ApplyTemplate();
// Available height 5, so scrollbar needed.
listBox.Measure(new Size(100, 5));

// Height should be 5.
Assert.Equal(5, listBox.DesiredSize.Height);

// ScrollViewer has VerticalScrollBarVisibility = true, so ScrollBar is always 1
// Width should be 4 (Item) + 1 (ScrollBar) = 5.
Assert.Equal(5, listBox.DesiredSize.Width);
}
Expand Down
175 changes: 175 additions & 0 deletions src/Tedd.TUI.Tests/ListBoxTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,66 @@ public void Render_UsesItemTemplate_WhenSet()
Assert.Equal("Bob", row1);
}

[Fact]
public void OnGotFocus_UpdatesSelectedItemVisualState()
{
// Arrange
var window = new TuiWindow();
var listBox = new ListBox();
window.Content = listBox;
listBox.Items.Add("Item 1");
listBox.Items.Add("Item 2");
listBox.SelectedIndex = 0;
listBox.Measure(new Size(20, 5));
listBox.Arrange(new Rect(0, 0, 20, 5));

var container = listBox.ItemsPanelRoot?.Children[0] as ListBoxItem;
Assert.NotNull(container);
Assert.True(container.IsSelected);

// Before focus, item should show unfocused selection colors
listBox.OnLostFocus();
var unfocusedBg = container.Background;
var unfocusedFg = container.Foreground;

// Act: ListBox gains focus
listBox.OnGotFocus();

// Assert: selected item should now show focused selection colors
Assert.NotEqual(unfocusedBg, container.Background);
Assert.NotEqual(unfocusedFg, container.Foreground);
Assert.Equal(listBox.FocusedSelectionBackground, container.Background);
Assert.Equal(listBox.FocusedSelectionForeground, container.Foreground);
}

[Fact]
public void OnLostFocus_UpdatesSelectedItemVisualState()
{
// Arrange
var window = new TuiWindow();
var listBox = new ListBox();
window.Content = listBox;
listBox.Items.Add("Item 1");
listBox.Items.Add("Item 2");
listBox.SelectedIndex = 0;
listBox.Measure(new Size(20, 5));
listBox.Arrange(new Rect(0, 0, 20, 5));

var container = listBox.ItemsPanelRoot?.Children[0] as ListBoxItem;
Assert.NotNull(container);

// First give focus so item shows focused selection colors
listBox.OnGotFocus();
Assert.Equal(listBox.FocusedSelectionBackground, container.Background);

// Act: ListBox loses focus
listBox.OnLostFocus();

// Assert: selected item should now show unfocused selection colors (ShowSelection=true by default)
Assert.Equal(listBox.SelectionBackground, container.Background);
Assert.Equal(listBox.SelectionForeground, container.Foreground);
}

[Fact]
public void Render_FallsBackToGetItemText_WhenNoTemplate()
{
Expand All @@ -101,4 +161,119 @@ public void Render_FallsBackToGetItemText_WhenNoTemplate()
Assert.Equal("Hello", row0);
Assert.Equal("World", row1);
}

[Fact]
public void ItemIsSelected_ViaIsSelectedProperty_UpdatesListBoxSelectedIndex()
{
var listBox = new ListBox();
listBox.Items.Add("Item 1");
listBox.Items.Add("Item 2");
listBox.Items.Add("Item 3");

// ItemsPanelRoot is populated after items are added (via OnItemsCollectionChanged β†’ PopulatePanel)
Assert.NotNull(listBox.ItemsPanelRoot);
var container1 = listBox.ItemsPanelRoot!.Children[1] as ListBoxItem;
Assert.NotNull(container1);

// Act: set IsSelected = true on the second container; this raises SelectedEvent which bubbles to ListBox
container1!.IsSelected = true;

// Assert: SelectedIndex should be updated to match the container's position
Assert.Equal(1, listBox.SelectedIndex);
}

[Fact]
public void ItemSelectedEvent_RaisedOnContainer_UpdatesListBoxSelectedIndex()
{
var listBox = new ListBox();
listBox.Items.Add("A");
listBox.Items.Add("B");
listBox.Items.Add("C");

Assert.NotNull(listBox.ItemsPanelRoot);
var container2 = listBox.ItemsPanelRoot!.Children[2] as ListBoxItem;
Assert.NotNull(container2);

// Act: raise SelectedEvent directly on the third container
container2!.RaiseEvent(new RoutedEventArgs(ListBoxItem.SelectedEvent, container2));

// Assert: SelectedIndex should be updated to 2
Assert.Equal(2, listBox.SelectedIndex);
}

[Fact]
public void SelectedIndex_Change_Updates_ContainersIsSelected()
{
var listBox = new ListBox();
listBox.Items.Add("Alpha");
listBox.Items.Add("Beta");
listBox.Items.Add("Gamma");

Assert.NotNull(listBox.ItemsPanelRoot);

// Act: select the second item
listBox.SelectedIndex = 1;

var container0 = listBox.ItemsPanelRoot!.Children[0] as ListBoxItem;
var container1 = listBox.ItemsPanelRoot!.Children[1] as ListBoxItem;
var container2 = listBox.ItemsPanelRoot!.Children[2] as ListBoxItem;

Assert.NotNull(container0);
Assert.NotNull(container1);
Assert.NotNull(container2);

Assert.False(container0!.IsSelected);
Assert.True(container1!.IsSelected);
Assert.False(container2!.IsSelected);
}

[Fact]
public void SelectedIndex_Change_Deselects_PreviousContainer()
{
var listBox = new ListBox();
listBox.Items.Add("X");
listBox.Items.Add("Y");
listBox.Items.Add("Z");

Assert.NotNull(listBox.ItemsPanelRoot);

// Select the first item
listBox.SelectedIndex = 0;
var container0 = listBox.ItemsPanelRoot!.Children[0] as ListBoxItem;
var container1 = listBox.ItemsPanelRoot!.Children[1] as ListBoxItem;
Assert.NotNull(container0);
Assert.NotNull(container1);
Assert.True(container0!.IsSelected);
Assert.False(container1!.IsSelected);

// Act: change selection to the second item
listBox.SelectedIndex = 1;

// Previous container should be deselected, new one selected
Assert.False(container0.IsSelected);
Assert.True(container1.IsSelected);
}

[Fact]
public void SelectedIndex_MinusOne_Deselects_AllContainers()
{
var listBox = new ListBox();
listBox.Items.Add("One");
listBox.Items.Add("Two");

Assert.NotNull(listBox.ItemsPanelRoot);

listBox.SelectedIndex = 0;
var container0 = listBox.ItemsPanelRoot!.Children[0] as ListBoxItem;
var container1 = listBox.ItemsPanelRoot!.Children[1] as ListBoxItem;
Assert.NotNull(container0);
Assert.NotNull(container1);
Assert.True(container0!.IsSelected);

// Act: clear selection
listBox.SelectedIndex = -1;

Assert.False(container0.IsSelected);
Assert.False(container1!.IsSelected);
}
}
Loading
Loading