diff --git a/src/AcDream.App/UI/Layout/JournalContractsPageController.cs b/src/AcDream.App/UI/Layout/JournalContractsPageController.cs
index e148f2e4..1791ec74 100644
--- a/src/AcDream.App/UI/Layout/JournalContractsPageController.cs
+++ b/src/AcDream.App/UI/Layout/JournalContractsPageController.cs
@@ -72,7 +72,16 @@ public sealed class JournalContractsPageController
private readonly UiText? _description;
private readonly UiText? _timedValue;
+ ///
+ /// Retail's own selection highlight for a listbox row. UiTemplateListBox
+ /// has no generic selection mechanism (its class doc), so the page opts in
+ /// directly — the same precedent
+ /// set.
+ ///
+ private static readonly Vector4 SelectedNameColor = Vector4.One;
+
private readonly List _rowContractIds = [];
+ private readonly List<(uint ContractId, UiText? Name, Vector4 Unselected)> _rows = [];
private long _renderedRevision = -1;
private uint _selectedContractId;
@@ -138,6 +147,7 @@ public sealed class JournalContractsPageController
_selectedContractId = 0u;
_rowContractIds.Clear();
+ _rows.Clear();
_list?.FlushPreservingScroll();
foreach (ContractTracker tracker in contracts)
@@ -152,7 +162,8 @@ public sealed class JournalContractsPageController
ContractEntry entry = catalog.Lookup(tracker.ContractId);
- if (UiElement.FindDescendant(row, RowNameId) is UiText name)
+ var name = UiElement.FindDescendant(row, RowNameId) as UiText;
+ if (name is not null)
SetText(name, entry.ContractName);
if (UiElement.FindDescendant(row, RowStatusId) is UiText status)
{
@@ -161,11 +172,23 @@ public sealed class JournalContractsPageController
tracker.ReceivedAt, entry, now));
}
+ // Captured AFTER SetText, which never touches DefaultColor (it is
+ // read lazily inside the provider closure), so this is the row's
+ // own authored colour to restore on deselect.
+ _rows.Add((tracker.ContractId, name, name?.DefaultColor ?? Vector4.One));
+
uint captured = tracker.ContractId;
if (row is UiDatElement clickable)
+ {
+ // A generic Type-3 container is click-THROUGH by default, so
+ // without this the handler below never fires and the list looks
+ // dead. UiDatElement carries the opt-in seam for exactly this.
+ clickable.ClickThrough = false;
clickable.OnClick = () => Select(captured);
+ }
}
+ ApplySelectionHighlight();
RefreshDetail();
}
@@ -173,9 +196,26 @@ public sealed class JournalContractsPageController
public void Select(uint contractId)
{
_selectedContractId = contractId;
+ ApplySelectionHighlight();
RefreshDetail();
}
+ ///
+ /// Re-painted rather than merely remembered: a rebuild discards the old row
+ /// objects, so a preserved selection has to be applied to the new ones.
+ ///
+ private void ApplySelectionHighlight()
+ {
+ foreach ((uint contractId, UiText? name, Vector4 unselected) in _rows)
+ {
+ if (name is not null)
+ {
+ name.DefaultColor =
+ contractId == _selectedContractId ? SelectedNameColor : unselected;
+ }
+ }
+ }
+
private void RefreshDetail()
{
ContractCatalog catalog = _bindings.Catalog();
diff --git a/tests/AcDream.App.Tests/UI/Layout/JournalContractsPageControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/JournalContractsPageControllerTests.cs
index 49b20d81..cfd1587e 100644
--- a/tests/AcDream.App.Tests/UI/Layout/JournalContractsPageControllerTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/JournalContractsPageControllerTests.cs
@@ -27,11 +27,20 @@ public sealed class JournalContractsPageControllerTests
private const uint DescriptionId = 0x100005DEu;
private const uint TimedValueId = 0x100005E3u;
+ ///
+ /// The authored row name/status colour. Deliberately NOT white: the
+ /// selection highlight paints white, so a fixture that starts white cannot
+ /// observe the highlight at all.
+ ///
+ private static readonly System.Numerics.Vector4 AuthoredTextColor =
+ new(0.8f, 0.8f, 0.8f, 1f);
+
private static UiText Text(uint id) => new()
{
DatElementId = id,
Width = 200f,
Height = 18f,
+ DefaultColor = AuthoredTextColor,
};
///
@@ -71,7 +80,15 @@ public sealed class JournalContractsPageControllerTests
return (page, list);
}
- /// One row: a name text and a status text, as retail authors.
+ ///
+ /// One row: a name text and a status text, as retail authors.
+ ///
+ ///
+ /// The root is a because that is what the real
+ /// Type-3 template resolves to through DatWidgetFactory's generic fallback
+ /// arm — and its click-through default is the entire point of one of the
+ /// tests below. A UiPanel here would make that test pass vacuously.
+ ///
private static UiElement? RowTemplate(uint layoutId, uint elementId)
{
if (layoutId != JournalContractsPageController.RowTemplateLayoutId
@@ -80,7 +97,15 @@ public sealed class JournalContractsPageControllerTests
return null;
}
- var row = new UiPanel { Width = 270f, Height = 16f };
+ var row = new UiDatElement(
+ new ElementInfo
+ {
+ Id = JournalContractsPageController.RowTemplateElementId,
+ Type = 3,
+ Width = 270,
+ Height = 20,
+ },
+ static _ => (0u, 0, 0));
row.AddChild(Text(RowNameId));
row.AddChild(Text(RowStatusId));
return row;
@@ -353,6 +378,113 @@ public sealed class JournalContractsPageControllerTests
Assert.Equal("Done (5m 0s to Repeat)", TextOf(page, StatusValueId));
}
+ [Fact]
+ public void RowsOptOutOfClickThroughOrTheListIsDead()
+ {
+ // The authored row template is a Type-3 generic container, and those
+ // are click-THROUGH by default — the click sails past the row to
+ // whatever is behind it and the handler never runs. Binding OnClick
+ // without clearing ClickThrough compiles, looks right, and produces a
+ // list you cannot select anything in.
+ (UiElement page, _) = BuildPage();
+ using var state = new RuntimeContractState();
+ Track(state, 0x10u, stage: 2u);
+
+ Bind(page, state, Catalog(Entry(0x10u, "First")));
+
+ UiDatElement row = Assert.IsAssignableFrom(Assert.Single(
+ UiElement.FindDescendant(page, ListId)!.Children.SelectMany(Flatten),
+ e => e is UiDatElement { OnClick: not null }));
+ Assert.False(row.ClickThrough);
+ }
+
+ [Fact]
+ public void ClickingARowSelectsIt()
+ {
+ (UiElement page, _) = BuildPage();
+ using var state = new RuntimeContractState();
+ Track(state, 0x10u, stage: 2u);
+ Track(state, 0x20u, stage: 2u);
+
+ JournalContractsPageController controller = Bind(page, state, Catalog(
+ Entry(0x10u, "First"), Entry(0x20u, "Second", description: "Second desc.")));
+ Assert.Equal(0x10u, controller.SelectedContractId);
+
+ UiDatElement second = Assert.IsAssignableFrom(
+ UiElement.FindDescendant(page, ListId)!.Children
+ .SelectMany(Flatten)
+ .Where(e => e is UiDatElement { OnClick: not null })
+ .ElementAt(1));
+ second.OnClick!();
+
+ Assert.Equal(0x20u, controller.SelectedContractId);
+ Assert.Equal("Second desc.", TextOf(page, DescriptionId));
+ }
+
+ [Fact]
+ public void TheSelectedRowIsHighlightedAndTheOthersAreNot()
+ {
+ // Without this the player cannot tell which row the detail pane is
+ // describing.
+ (UiElement page, _) = BuildPage();
+ using var state = new RuntimeContractState();
+ Track(state, 0x10u, stage: 2u);
+ Track(state, 0x20u, stage: 2u);
+
+ JournalContractsPageController controller = Bind(page, state, Catalog(
+ Entry(0x10u, "First"), Entry(0x20u, "Second")));
+
+ UiText[] names = UiElement.FindDescendant(page, ListId)!.Children
+ .SelectMany(Flatten)
+ .OfType()
+ .Where(t => t.DatElementId == RowNameId)
+ .ToArray();
+ Assert.Equal(2, names.Length);
+
+ // Row 0 opens selected (no display contract, so the first row stands).
+ System.Numerics.Vector4 selectedColor = names[0].DefaultColor;
+ System.Numerics.Vector4 unselectedColor = names[1].DefaultColor;
+ Assert.NotEqual(unselectedColor, selectedColor);
+
+ controller.Select(0x20u);
+
+ // The highlight MOVES: the old row goes back to its authored colour
+ // rather than both rows ending up lit.
+ Assert.Equal(unselectedColor, names[0].DefaultColor);
+ Assert.Equal(selectedColor, names[1].DefaultColor);
+ }
+
+ [Fact]
+ public void TheHighlightSurvivesARebuild()
+ {
+ // A rebuild discards the row objects, so a remembered selection has to
+ // be re-PAINTED onto the new ones rather than merely kept.
+ (UiElement page, _) = BuildPage();
+ using var state = new RuntimeContractState();
+ Track(state, 0x10u, stage: 2u);
+ Track(state, 0x20u, stage: 2u);
+
+ JournalContractsPageController controller = Bind(page, state, Catalog(
+ Entry(0x10u, "First"), Entry(0x20u, "Second")));
+ controller.Select(0x20u);
+
+ Track(state, 0x30u, stage: 2u); // forces a rebuild
+ controller.Tick();
+
+ Assert.Equal(0x20u, controller.SelectedContractId);
+ UiText selected = UiElement.FindDescendant(page, ListId)!.Children
+ .SelectMany(Flatten)
+ .OfType()
+ .Where(t => t.DatElementId == RowNameId)
+ .ElementAt(1);
+ UiText unselected = UiElement.FindDescendant(page, ListId)!.Children
+ .SelectMany(Flatten)
+ .OfType()
+ .Where(t => t.DatElementId == RowNameId)
+ .ElementAt(0);
+ Assert.NotEqual(unselected.DefaultColor, selected.DefaultColor);
+ }
+
[Fact]
public void AProgressCounterRendersThroughTheAuthoredFormat()
{