fix(ui): share retail tab state binding

Replace the character window's synthesized tab sprites, labels, colors, and hit panels with the same DAT-authored Open/Closed state path used by Spellbook/Components. Preserve controller-owned page selection while letting UIElement state propagation own tab chrome and typography.

Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-15 17:21:16 +02:00
parent 1249ad03de
commit 15bb61e05d
5 changed files with 179 additions and 285 deletions

View file

@ -775,73 +775,56 @@ public class CharacterStatControllerTests
Assert.False(btn1.Visible, "raise button hidden after deselect");
}
// ── Pass 2: Tab button states ─────────────────────────────────────────────
// Tab sprites are added to layout.Root (NOT to the tab group elements), so
// the tab groups keep Children.Count==0 and survive the page-visibility pass.
// AddTabSpritesToRoot() adds 3 sprite UiTexts + 1 label UiText per tab to the
// root when a spriteResolve is provided; nothing is added when spriteResolve=null.
// ── Pass 2: DAT-authored tab states ───────────────────────────────────────
[Fact]
public void TabButtons_NoSpriteResolve_AddsNoSpriteChildrenToRoot()
public void CharacterTabs_UseImportedChromeWithoutSyntheticRootChildren()
{
// When spriteResolve is null, AddTabSpritesToRoot is not called — no sprites added.
var layout = Fake(); // empty fake layout with just the root UiPanel
int rootChildCountBefore = layout.Root.Children.Count;
var layout = FixtureLoader.LoadCharacter();
int rootChildCount = layout.Root.Children.Count;
var attributes = Assert.IsType<UiText>(layout.FindElement(CharacterStatController.TabAttribId));
var skills = Assert.IsType<UiText>(layout.FindElement(CharacterStatController.TabSkillsId));
var titles = Assert.IsType<UiText>(layout.FindElement(CharacterStatController.TabTitlesId));
CharacterStatController.Bind(layout, SampleData.SampleCharacter, spriteResolve: null);
// No extra children added to root when spriteResolve is null.
Assert.Equal(rootChildCountBefore, layout.Root.Children.Count);
}
[Fact]
public void TabButtons_AttributesGroup_AddsOpenSpriteIdsToRoot()
{
// Attributes tab (isOpen=true): 3 sprite children with Open sprite ids on ROOT.
// The tab group element itself has 0 children (sprites go to root, not the group).
var layout = Fake(); // minimal fake
Assert.Equal(3, attributes.Children.Count);
Assert.Equal(3, skills.Children.Count);
Assert.Equal(3, titles.Children.Count);
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
spriteResolve: id => (id, 16, 16));
// Root should contain the Open sprite ids among all tab sprite children.
var sprites = layout.Root.Children.OfType<UiText>()
.Where(t => t.BackgroundSprite != 0u).ToList();
Assert.Contains(sprites, t => t.BackgroundSprite == 0x06005D92u); // Open left-cap
Assert.Contains(sprites, t => t.BackgroundSprite == 0x06005D94u); // Open center
Assert.Contains(sprites, t => t.BackgroundSprite == 0x06005D96u); // Open right-cap
Assert.Equal(rootChildCount, layout.Root.Children.Count);
Assert.Equal(RetailUiStateIds.Open, attributes.ActiveRetailStateId);
Assert.Equal(RetailUiStateIds.Closed, skills.ActiveRetailStateId);
Assert.Equal(RetailUiStateIds.Closed, titles.ActiveRetailStateId);
Assert.Equal(
new uint[] { 0x06005D92u, 0x06005D94u, 0x06005D96u },
attributes.Children.Cast<UiDatElement>().Select(child => child.ActiveMedia().File));
Assert.Equal(
new uint[] { 0x06005D93u, 0x06005D95u, 0x06005D97u },
skills.Children.Cast<UiDatElement>().Select(child => child.ActiveMedia().File));
Assert.True(titles.ClickThrough);
}
[Fact]
public void TabButtons_SkillsAndTitles_AddClosedSpriteIdsToRoot()
public void CharacterTabs_ClickUsesRetailStateColorAndPropagatesToChrome()
{
// Skills + Titles tabs (isOpen=false): Closed sprite ids appear on root.
var layout = Fake();
var layout = FixtureLoader.LoadCharacter();
var attributes = Assert.IsType<UiText>(layout.FindElement(CharacterStatController.TabAttribId));
var skills = Assert.IsType<UiText>(layout.FindElement(CharacterStatController.TabSkillsId));
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
spriteResolve: id => (id, 16, 16));
skills.OnClick!();
var sprites = layout.Root.Children.OfType<UiText>()
.Where(t => t.BackgroundSprite != 0u).ToList();
Assert.Contains(sprites, t => t.BackgroundSprite == 0x06005D93u); // Closed left-cap
Assert.Contains(sprites, t => t.BackgroundSprite == 0x06005D95u); // Closed center
Assert.Contains(sprites, t => t.BackgroundSprite == 0x06005D97u); // Closed right-cap
}
[Fact]
public void TabButtons_WithSpriteResolve_AddsAllThreeTabsToRoot()
{
// With spriteResolve: all 3 tabs inject 4 children each (3 sprites + 1 label)
// = 12 sprite children total across 3 tabs on the root.
var layout = Fake();
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
spriteResolve: id => (id, 16, 16));
// 3 tabs × 3 sprites = 9 sprite UiTexts on root.
var sprites = layout.Root.Children.OfType<UiText>()
.Where(t => t.BackgroundSprite != 0u).ToList();
Assert.Equal(9, sprites.Count);
Assert.Equal(RetailUiStateIds.Closed, attributes.ActiveRetailStateId);
Assert.Equal(RetailUiStateIds.Open, skills.ActiveRetailStateId);
Assert.Equal(127f / 255f, attributes.DefaultColor.X, 5);
Assert.Equal(204f / 255f, skills.DefaultColor.X, 5);
Assert.All(attributes.Children, child =>
Assert.Equal(RetailUiStateIds.Closed, Assert.IsAssignableFrom<IUiDatStateful>(child).ActiveRetailStateId));
Assert.All(skills.Children, child =>
Assert.Equal(RetailUiStateIds.Open, Assert.IsAssignableFrom<IUiDatStateful>(child).ActiveRetailStateId));
}
// ── Affordability helpers (GetRaiseCost) ──────────────────────────────────
@ -934,12 +917,15 @@ public class CharacterStatControllerTests
hiddenPage.AddChild(hiddenDuplicateList);
root.AddChild(attrPage);
root.AddChild(hiddenPage);
var skillsTab = MakeTab(CharacterStatController.TabSkillsId, left: 92f);
root.AddChild(skillsTab);
var layout = new ImportedLayout(root, new Dictionary<uint, UiElement>
{
[CharacterStatController.NameId] = name,
// Mirrors the real import: the id dictionary can point at a hidden duplicate.
[CharacterStatController.ListBoxId] = hiddenDuplicateList,
[CharacterStatController.TabSkillsId] = skillsTab,
});
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
@ -950,7 +936,7 @@ public class CharacterStatControllerTests
var ui = new UiRoot { Width = 300, Height = 600 };
ui.AddChild(root);
Assert.IsType<UiClickablePanel>(ui.Pick(132, 12));
Assert.Same(skillsTab, ui.Pick(132, 12));
ui.OnMouseDown(UiMouseButton.Left, 132, 12);
ui.OnMouseUp(UiMouseButton.Left, 132, 12);
@ -1215,12 +1201,15 @@ public class CharacterStatControllerTests
page.AddChild(list);
page.AddChild(scrollbarShell);
root.AddChild(page);
var skillsTab = MakeTab(CharacterStatController.TabSkillsId, left: 92f);
root.AddChild(skillsTab);
var layout = new ImportedLayout(root, new Dictionary<uint, UiElement>
{
[CharacterStatController.NameId] = name,
[CharacterStatController.ListBoxId] = list,
[CharacterStatController.ListScrollbarId] = scrollbarShell,
[CharacterStatController.TabSkillsId] = skillsTab,
});
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
@ -1589,10 +1578,13 @@ public class CharacterStatControllerTests
private static void ClickTab(ImportedLayout layout, float left)
{
var tab = layout.Root.Children.OfType<UiClickablePanel>()
.Single(p => System.Math.Abs(p.Left - left) < 0.5f
&& System.Math.Abs(p.Top) < 0.5f
&& System.Math.Abs(p.Height - 25f) < 0.5f);
uint id = left switch
{
0f => CharacterStatController.TabAttribId,
92f => CharacterStatController.TabSkillsId,
_ => CharacterStatController.TabTitlesId,
};
var tab = Assert.IsType<UiText>(layout.FindElement(id));
tab.OnClick!();
}
@ -1660,6 +1652,33 @@ public class CharacterStatControllerTests
return new UiButton(info, static _ => (0u, 0, 0));
}
private static UiText MakeTab(uint id, float left)
{
var info = new ElementInfo
{
Id = id,
Type = 12,
X = left,
Width = 92f,
Height = 25f,
DefaultStateId = RetailUiStateIds.Closed,
DefaultStateName = "Closed",
};
info.States[RetailUiStateIds.Closed] = new UiStateInfo
{
Id = RetailUiStateIds.Closed,
Name = "Closed",
PassToChildren = true,
};
info.States[RetailUiStateIds.Open] = new UiStateInfo
{
Id = RetailUiStateIds.Open,
Name = "Open",
PassToChildren = true,
};
return Assert.IsType<UiText>(DatWidgetFactory.Create(info, static _ => (0u, 0, 0), null));
}
private static ImportedLayout Fake(params (uint id, UiElement e)[] items)
{
var dict = new Dictionary<uint, UiElement>();
@ -1669,6 +1688,18 @@ public class CharacterStatControllerTests
root.AddChild(e);
dict[id] = e;
}
foreach ((uint id, float left) in new[]
{
(CharacterStatController.TabAttribId, 0f),
(CharacterStatController.TabSkillsId, 92f),
(CharacterStatController.TabTitlesId, 184f),
})
{
if (dict.ContainsKey(id)) continue;
UiText tab = MakeTab(id, left);
root.AddChild(tab);
dict[id] = tab;
}
return new ImportedLayout(root, dict);
}
}