docs(D.2b): widget-generalization implementation plan
8-task TDD plan: chat golden fixture + resolved-Type conformance (Task 1, empirically resolves the input's Type), then one-widget-per-commit migration — UiScrollbar(11), UiButton(1), UiMenu(6), UiText(12)+the Type-12 flip, UiField(3) — then thin the controller (Task 7, visual gate) and the gated vitals UiText rewire (Task 8). Each task: failing test, register in the factory switch, controller find-by-id binding, build+test green, commit. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
56f5bc7aa1
commit
34e79096f3
1 changed files with 992 additions and 0 deletions
992
docs/superpowers/plans/2026-06-16-d2b-widget-generalization.md
Normal file
992
docs/superpowers/plans/2026-06-16-d2b-widget-generalization.md
Normal file
|
|
@ -0,0 +1,992 @@
|
||||||
|
# D.2b Widget Generalization Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Refactor the hand-named chat widgets and the Send/Max-Min click-wiring into generic, Type-registered widgets built by `DatWidgetFactory`, collapsing the controllers to a thin retail `gm*UI::PostInit`-style find-by-id binder.
|
||||||
|
|
||||||
|
**Architecture:** `DatWidgetFactory.Create` grows a faithful `switch(Type)` registering the real retail `UIElement` classes (Button=1, Field=3, Menu=6, Meter=7, Scrollbar=11, Text=12) as generic widgets; everything else stays `UiDatElement`. The importer's base-chain Type resolution already surfaces each element's real Type, so this is a *registration* task. The chat-specific knowledge (channel list, colors, command routing) moves out of widgets into `ChatWindowController`. Migrate one widget per commit; chat stays visually identical through Tasks 2–7; vitals is rewired last (Task 8) behind a visual gate.
|
||||||
|
|
||||||
|
**Tech Stack:** C# / .NET 10, xUnit, `DatReaderWriter` (Chorizite), Silk.NET (GL/input). Retail oracle: `docs/research/named-retail/acclient_2013_pseudo_c.txt`.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-06-16-d2b-widget-generalization-design.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- **Repo root** = the worktree dir. All paths below are relative to it.
|
||||||
|
- **Build:** `dotnet build` (builds `AcDream.slnx`). Must be green before every commit.
|
||||||
|
- **Test (all UI):** `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj`
|
||||||
|
- **Test (filtered):** add `--filter "FullyQualifiedName~<ClassName>"`.
|
||||||
|
- **Commit style:** `feat(D.2b): <widget> — <what>` / `test(D.2b): …` / `refactor(D.2b): …`, ending with the project's `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>` trailer.
|
||||||
|
- **Every generic widget cites its retail class + `RegisterElementClass` line** in a doc comment (per spec §8).
|
||||||
|
- **Divergence register:** `docs/architecture/retail-divergence-register.md` — amend AP-37 / re-check AP-41 in the same commit that lands the relevant widget (per spec §7).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
**Created:**
|
||||||
|
- `src/AcDream.App/UI/UiButton.cs` — generic Type-1 button (Task 3).
|
||||||
|
- `src/AcDream.App/UI/UiText.cs` — generic Type-12 scrollable colored-line text (rename of `UiChatView`, Task 5).
|
||||||
|
- `src/AcDream.App/UI/UiField.cs` — generic Type-3 editable one-line field (rename of `UiChatInput`, Task 6).
|
||||||
|
- `src/AcDream.App/UI/UiScrollbar.cs` — generic Type-11 scrollbar (rename of `UiChatScrollbar`, Task 2).
|
||||||
|
- `src/AcDream.App/UI/UiMenu.cs` — generic Type-6 dropdown menu (genericized `UiChannelMenu`, Task 4).
|
||||||
|
- `tests/AcDream.App.Tests/UI/Layout/fixtures/chat_21000006.json` — golden resolved chat tree (Task 1).
|
||||||
|
- `tests/AcDream.App.Tests/UI/Layout/ChatLayoutFixtureGenerator.cs` — skip-by-default fixture generator (Task 1).
|
||||||
|
- `tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs` — resolved-tree + factory-class conformance (Task 1, grown per widget).
|
||||||
|
- `tests/AcDream.App.Tests/UI/UiButtonTests.cs` (Task 3).
|
||||||
|
|
||||||
|
**Renamed (git mv + class/namespace-internal rename):**
|
||||||
|
- `UiChatScrollbar.cs` → `UiScrollbar.cs`; `UiChatScrollbarTests.cs` → `UiScrollbarTests.cs` (Task 2).
|
||||||
|
- `UiChatView.cs` → `UiText.cs`; `UiChatViewTests.cs` → `UiTextTests.cs`; `UiChatViewDatFontTests.cs` → `UiTextDatFontTests.cs` (Task 5).
|
||||||
|
- `UiChatInput.cs` → `UiField.cs`; `UiChatInputTests.cs` → `UiFieldTests.cs` (Task 6).
|
||||||
|
- `UiChannelMenu.cs` → `UiMenu.cs`; `UiChannelMenuTests.cs` → `UiMenuTests.cs` (Task 4).
|
||||||
|
|
||||||
|
**Modified:**
|
||||||
|
- `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` — the `switch(Type)` + `BuildButton`/`BuildMenu`/`BuildText`/`BuildField`/`BuildScrollbar` (Tasks 2–6).
|
||||||
|
- `src/AcDream.App/UI/Layout/ChatWindowController.cs` — construction → find-by-id binding; channel-item population (Tasks 2–7).
|
||||||
|
- `src/AcDream.App/UI/Layout/VitalsController.cs` — bind `UiText` numbers (Task 8).
|
||||||
|
- `src/AcDream.App/Rendering/GameWindow.cs` — only property-type follow-through (`.Transcript`/`.Input` types change) if needed (Tasks 5–6).
|
||||||
|
- `tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs` — new per-Type asserts; flip the two Type-12 tests (Tasks 2–6).
|
||||||
|
- `tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs` — add `LoadChat()` (Task 1).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Chat golden fixture + conformance test (also resolves the input's Type empirically)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `tests/AcDream.App.Tests/UI/Layout/ChatLayoutFixtureGenerator.cs`
|
||||||
|
- Create: `tests/AcDream.App.Tests/UI/Layout/fixtures/chat_21000006.json` (generated, committed)
|
||||||
|
- Modify: `tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs`
|
||||||
|
- Create: `tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs`
|
||||||
|
|
||||||
|
The generator runs once against the live dat (it is `[Fact(Skip=…)]` so CI never runs it). The committed JSON is dat-free, like `vitals_2100006C.json`. The fixture's resolved `Type` per element **answers spec verification #1** (does input `0x10000016` resolve to 3 or 12?).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the generator (skip-by-default).**
|
||||||
|
|
||||||
|
`ChatLayoutFixtureGenerator.cs`:
|
||||||
|
```csharp
|
||||||
|
using System.IO;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Text.Json;
|
||||||
|
using AcDream.App.UI.Layout;
|
||||||
|
using DatReaderWriter;
|
||||||
|
using DatReaderWriter.Options;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.UI.Layout;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One-off generator for the committed chat golden fixture. Skipped by default —
|
||||||
|
/// run manually with the real dats present (set ACDREAM_DAT_DIR) to regenerate
|
||||||
|
/// chat_21000006.json, then commit it. Mirrors how vitals_2100006C.json was made.
|
||||||
|
/// </summary>
|
||||||
|
public class ChatLayoutFixtureGenerator
|
||||||
|
{
|
||||||
|
[Fact(Skip = "manual: regenerates the committed chat fixture; needs the real dats (ACDREAM_DAT_DIR)")]
|
||||||
|
public void GenerateChatFixture()
|
||||||
|
{
|
||||||
|
var datDir = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
|
||||||
|
?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
||||||
|
"Documents", "Asheron's Call");
|
||||||
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||||
|
var info = LayoutImporter.ImportInfos(dats, 0x21000006u);
|
||||||
|
Assert.NotNull(info);
|
||||||
|
|
||||||
|
var json = JsonSerializer.Serialize(info, new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
IncludeFields = true,
|
||||||
|
WriteIndented = true,
|
||||||
|
});
|
||||||
|
File.WriteAllText(FixturePath(), json);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the SOURCE fixtures dir (not bin/) from this file's compile-time path.
|
||||||
|
private static string FixturePath([CallerFilePath] string thisFile = "")
|
||||||
|
=> Path.Combine(Path.GetDirectoryName(thisFile)!, "fixtures", "chat_21000006.json");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Generate the fixture (manual, dats present).**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~ChatLayoutFixtureGenerator.GenerateChatFixture" -e ACDREAM_DAT_DIR="%USERPROFILE%\Documents\Asheron's Call"` after temporarily removing the `Skip` (or use an IDE run). Confirm `tests/AcDream.App.Tests/UI/Layout/fixtures/chat_21000006.json` is written and non-empty, then restore the `Skip`.
|
||||||
|
Expected: a JSON tree rooted at id `0x10000006`-family with the chat elements. **Record the resolved `Type` of `0x10000016` (input) and `0x10000011` (transcript)** — these drive Task 5/6 decisions.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add `FixtureLoader.LoadChat()` + `LoadChatInfos()`.**
|
||||||
|
|
||||||
|
In `FixtureLoader.cs`, add (mirroring `LoadVitals`/`LoadVitalsInfos`):
|
||||||
|
```csharp
|
||||||
|
public static ImportedLayout LoadChat()
|
||||||
|
=> LayoutImporter.Build(LoadChatInfos(), _ => (0u, 0, 0), null);
|
||||||
|
|
||||||
|
public static AcDream.App.UI.Layout.ElementInfo LoadChatInfos()
|
||||||
|
=> LoadInfos("chat_21000006.json");
|
||||||
|
|
||||||
|
// Shared loader (refactor LoadVitalsInfos to call this with "vitals_2100006C.json").
|
||||||
|
private static AcDream.App.UI.Layout.ElementInfo LoadInfos(string fileName)
|
||||||
|
{
|
||||||
|
var path = Path.Combine(AppContext.BaseDirectory, "UI", "Layout", "fixtures", fileName);
|
||||||
|
if (!File.Exists(path)) throw new FileNotFoundException($"fixture not found at: {path}");
|
||||||
|
var bytes = File.ReadAllBytes(path);
|
||||||
|
ReadOnlySpan<byte> span = bytes;
|
||||||
|
if (span.Length >= 3 && span[0] == 0xEF && span[1] == 0xBB && span[2] == 0xBF) span = span[3..];
|
||||||
|
return JsonSerializer.Deserialize<AcDream.App.UI.Layout.ElementInfo>(span, _opts)
|
||||||
|
?? throw new InvalidOperationException($"fixture deserialized to null: {path}");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Then make `LoadVitalsInfos()` delegate: `public static ElementInfo LoadVitalsInfos() => LoadInfos("vitals_2100006C.json");`
|
||||||
|
|
||||||
|
- [ ] **Step 4: Write the resolved-tree conformance test (fails until the fixture exists).**
|
||||||
|
|
||||||
|
`ChatLayoutConformanceTests.cs`:
|
||||||
|
```csharp
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using AcDream.App.UI.Layout;
|
||||||
|
namespace AcDream.App.Tests.UI.Layout;
|
||||||
|
|
||||||
|
public class ChatLayoutConformanceTests
|
||||||
|
{
|
||||||
|
private static ElementInfo Find(ElementInfo n, uint id)
|
||||||
|
{
|
||||||
|
if (n.Id == id) return n;
|
||||||
|
foreach (var c in n.Children) { var f = Find(c, id); if (f is not null) return f; }
|
||||||
|
return null!;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ChatFixture_ResolvesKnownElements()
|
||||||
|
{
|
||||||
|
var root = FixtureLoader.LoadChatInfos();
|
||||||
|
// These ids come from ChatWindowController; the resolved Type proves the base-chain merge.
|
||||||
|
Assert.NotNull(Find(root, 0x10000011u)); // transcript
|
||||||
|
Assert.NotNull(Find(root, 0x10000016u)); // input
|
||||||
|
Assert.NotNull(Find(root, 0x10000012u)); // scrollbar track
|
||||||
|
Assert.NotNull(Find(root, 0x10000014u)); // channel menu
|
||||||
|
Assert.NotNull(Find(root, 0x10000019u)); // send button
|
||||||
|
Assert.NotNull(Find(root, 0x1000046Fu)); // max/min button
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ChatFixture_ResolvedTypes_MatchRetailRegistry()
|
||||||
|
{
|
||||||
|
var root = FixtureLoader.LoadChatInfos();
|
||||||
|
Assert.Equal(6u, Find(root, 0x10000014u).Type); // Menu
|
||||||
|
Assert.Equal(11u, Find(root, 0x10000012u).Type); // Scrollbar
|
||||||
|
Assert.Equal(1u, Find(root, 0x10000019u).Type); // Button (Send)
|
||||||
|
Assert.Equal(1u, Find(root, 0x1000046Fu).Type); // Button (Max/Min)
|
||||||
|
// transcript + input: assert the ACTUAL resolved Type recorded in Step 2.
|
||||||
|
// From the Map trace both resolve to 12 (Text); if Step 2 shows otherwise, update these.
|
||||||
|
Assert.Equal(12u, Find(root, 0x10000011u).Type); // Text (transcript)
|
||||||
|
Assert.Equal(12u, Find(root, 0x10000016u).Type); // Text (input — see Task 6 wrinkle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run the conformance tests.**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~ChatLayoutConformanceTests"`
|
||||||
|
Expected: PASS. If `ChatFixture_ResolvedTypes_MatchRetailRegistry` shows input `0x10000016` Type ≠ 12, **update the assert to the real value and note it in Task 6 Step 1** (decides factory-built vs controller-placed `UiField`).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit.**
|
||||||
|
```bash
|
||||||
|
git add tests/AcDream.App.Tests/UI/Layout/ChatLayoutFixtureGenerator.cs \
|
||||||
|
tests/AcDream.App.Tests/UI/Layout/fixtures/chat_21000006.json \
|
||||||
|
tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs \
|
||||||
|
tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs
|
||||||
|
git commit -m "test(D.2b): chat golden fixture + resolved-Type conformance (widget-generalization Task 1)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: `UiScrollbar` (Type 11) — promote the already-generic scrollbar
|
||||||
|
|
||||||
|
`UiChatScrollbar` has zero chat-specific code; this is a rename + factory registration.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Rename: `src/AcDream.App/UI/UiChatScrollbar.cs` → `src/AcDream.App/UI/UiScrollbar.cs`
|
||||||
|
- Rename: `tests/AcDream.App.Tests/UI/UiChatScrollbarTests.cs` → `tests/AcDream.App.Tests/UI/UiScrollbarTests.cs`
|
||||||
|
- Modify: `src/AcDream.App/UI/Layout/DatWidgetFactory.cs`
|
||||||
|
- Modify: `src/AcDream.App/UI/Layout/ChatWindowController.cs`
|
||||||
|
- Modify: `tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs`, `ChatLayoutConformanceTests.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Rename the widget file + class.**
|
||||||
|
```bash
|
||||||
|
git mv src/AcDream.App/UI/UiChatScrollbar.cs src/AcDream.App/UI/UiScrollbar.cs
|
||||||
|
git mv tests/AcDream.App.Tests/UI/UiChatScrollbarTests.cs tests/AcDream.App.Tests/UI/UiScrollbarTests.cs
|
||||||
|
```
|
||||||
|
In `UiScrollbar.cs`: rename `class UiChatScrollbar` → `class UiScrollbar`; update the doc summary to "Generic scrollbar. Ports retail `UIElement_Scrollbar` (RegisterElementClass(0xb) @ acclient_2013_pseudo_c.txt:124137)…"; keep all body/fields/methods unchanged.
|
||||||
|
In `UiScrollbarTests.cs`: rename the test class to `UiScrollbarTests`; replace every `UiChatScrollbar` with `UiScrollbar`. (Keep the test bodies.)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write the failing factory test.**
|
||||||
|
|
||||||
|
In `DatWidgetFactoryTests.cs` add:
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public void Type11_Scrollbar_MakesUiScrollbar()
|
||||||
|
{
|
||||||
|
var e = DatWidgetFactory.Create(new ElementInfo { Type = 11, Width = 16, Height = 68 }, NoTex, null);
|
||||||
|
Assert.IsType<UiScrollbar>(e);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run it — verify it fails.**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~DatWidgetFactoryTests.Type11_Scrollbar_MakesUiScrollbar"`
|
||||||
|
Expected: FAIL (`Create` returns `UiDatElement`, not `UiScrollbar`).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Register Type 11 in the factory.**
|
||||||
|
|
||||||
|
In `DatWidgetFactory.Create`, add to the switch (before `_`):
|
||||||
|
```csharp
|
||||||
|
11 => new UiScrollbar(), // UIElement_Scrollbar (reg :124137)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Build + run factory + scrollbar tests.**
|
||||||
|
|
||||||
|
Run: `dotnet build` then `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~DatWidgetFactoryTests|FullyQualifiedName~UiScrollbarTests"`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Point the controller at the factory-built scrollbar (still functional).**
|
||||||
|
|
||||||
|
The factory now builds a `UiScrollbar` for the Type-11 track element. In `ChatWindowController.cs`, in the "Scrollbar — replace the imported track placeholder" block, change the construction to bind the factory widget instead of building a fresh one. Replace the block (currently `c.Scrollbar = new UiChatScrollbar { … }; trackParent.RemoveChild(track); trackParent.AddChild(c.Scrollbar);`) with:
|
||||||
|
```csharp
|
||||||
|
// The factory built the Type-11 track element as a UiScrollbar. Find it, bind it.
|
||||||
|
if (layout.FindElement(TrackId) is UiScrollbar bar)
|
||||||
|
{
|
||||||
|
bar.Top = 0f; // pull up to the panel top (resize-bar reclaim)
|
||||||
|
bar.Height = bar.Height + bar.Top; // NOTE: capture old Top before zeroing — see Step 6a
|
||||||
|
bar.Model = c.Transcript.Scroll;
|
||||||
|
bar.SpriteResolve = resolve;
|
||||||
|
bar.TrackSprite = TrackSprite;
|
||||||
|
bar.ThumbSprite = ThumbSprite;
|
||||||
|
bar.ThumbTopSprite = ThumbTopSprite;
|
||||||
|
bar.ThumbBotSprite = ThumbBotSprite;
|
||||||
|
bar.UpSprite = UpSprite;
|
||||||
|
bar.DownSprite = DownSprite;
|
||||||
|
c.Scrollbar = bar;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- [ ] **Step 6a: Fix the Top/Height order bug introduced above.** The old code added `track.Top` to height *before* zeroing Top. Write it correctly:
|
||||||
|
```csharp
|
||||||
|
if (layout.FindElement(TrackId) is UiScrollbar bar)
|
||||||
|
{
|
||||||
|
float oldTop = bar.Top;
|
||||||
|
bar.Top = 0f;
|
||||||
|
bar.Height = bar.Height + oldTop;
|
||||||
|
bar.Model = c.Transcript.Scroll;
|
||||||
|
bar.SpriteResolve = resolve;
|
||||||
|
bar.TrackSprite = TrackSprite; bar.ThumbSprite = ThumbSprite;
|
||||||
|
bar.ThumbTopSprite = ThumbTopSprite; bar.ThumbBotSprite = ThumbBotSprite;
|
||||||
|
bar.UpSprite = UpSprite; bar.DownSprite = DownSprite;
|
||||||
|
c.Scrollbar = bar;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Change the `Scrollbar` property type: `public UiScrollbar Scrollbar { get; private set; } = null!;`
|
||||||
|
|
||||||
|
- [ ] **Step 7: Update the conformance Type assert (already Type 11) + run full UI suite.**
|
||||||
|
|
||||||
|
`ChatLayoutConformanceTests` already asserts Type 11 for the track. Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj`
|
||||||
|
Expected: PASS (whole UI suite).
|
||||||
|
|
||||||
|
- [ ] **Step 8: Re-check AP-41 in the divergence register.**
|
||||||
|
|
||||||
|
The controller passes `ThumbTopSprite`/`ThumbBotSprite` (3-slice caps), so AP-41 ("thumb single stretched sprite") is stale. In `docs/architecture/retail-divergence-register.md`, update the AP-41 `file:line` from `UiChatScrollbar.cs:37` to `UiScrollbar.cs` and narrow/retire it (the 3-slice path now draws caps; retire only if the fallback single-tile path is no longer reachable — it is reachable when caps are 0, so narrow the wording to "fallback only").
|
||||||
|
|
||||||
|
- [ ] **Step 9: Commit.**
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
git commit -m "feat(D.2b): UiScrollbar (Type 11) — promote the generic chat scrollbar (widget-generalization Task 2)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: `UiButton` (Type 1) — Send + Max/Min
|
||||||
|
|
||||||
|
The factory currently builds Send/Max-Min as `UiDatElement` and the controller sets `OnClick`/`Label`. Introduce a dedicated `UiButton` mirroring that behavior exactly (so clicks don't regress) and register Type 1.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/AcDream.App/UI/UiButton.cs`
|
||||||
|
- Create: `tests/AcDream.App.Tests/UI/UiButtonTests.cs`
|
||||||
|
- Modify: `DatWidgetFactory.cs`, `ChatWindowController.cs`, `DatWidgetFactoryTests.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing button-behavior test.**
|
||||||
|
|
||||||
|
`UiButtonTests.cs`:
|
||||||
|
```csharp
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.App.UI;
|
||||||
|
using AcDream.App.UI.Layout;
|
||||||
|
namespace AcDream.App.Tests.UI;
|
||||||
|
|
||||||
|
public class UiButtonTests
|
||||||
|
{
|
||||||
|
private static (uint, int, int) NoTex(uint _) => (0, 0, 0);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Click_InvokesOnClick()
|
||||||
|
{
|
||||||
|
var info = new ElementInfo { Type = 1, Width = 46, Height = 18 };
|
||||||
|
var b = new UiButton(info, NoTex) { OnClick = () => Clicked = true };
|
||||||
|
b.OnEvent(new UiEvent(UiEventType.Click, 0, 0, 0));
|
||||||
|
Assert.True(Clicked);
|
||||||
|
}
|
||||||
|
private bool Clicked;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NotClickThrough_SoItReceivesClicks()
|
||||||
|
{
|
||||||
|
var b = new UiButton(new ElementInfo { Type = 1 }, NoTex);
|
||||||
|
Assert.False(b.ClickThrough);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
> Confirm the `UiEvent` constructor signature in `src/AcDream.App/UI/UiEvent.cs` before finalizing the `new UiEvent(...)` call; adjust arg order if needed.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run it — verify it fails (UiButton does not exist).**
|
||||||
|
|
||||||
|
Run: `dotnet test … --filter "FullyQualifiedName~UiButtonTests"`
|
||||||
|
Expected: FAIL (compile error: `UiButton` not found).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write `UiButton`.**
|
||||||
|
|
||||||
|
`UiButton.cs`:
|
||||||
|
```csharp
|
||||||
|
using System;
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.App.UI.Layout;
|
||||||
|
|
||||||
|
namespace AcDream.App.UI;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generic clickable button. Ports retail UIElement_Button
|
||||||
|
/// (RegisterElementClass(1, UIElement_Button::Create) @ acclient_2013_pseudo_c.txt:125828):
|
||||||
|
/// a per-state sprite face + an optional centered caption + a click action. Built by
|
||||||
|
/// DatWidgetFactory for Type-1 elements (chat Send 0x10000019, Max/Min 0x1000046F).
|
||||||
|
/// The controller binds OnClick and the caption. State selection mirrors UiDatElement
|
||||||
|
/// so existing Send/Max-Min behavior is preserved exactly.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class UiButton : UiElement
|
||||||
|
{
|
||||||
|
private readonly ElementInfo _info;
|
||||||
|
private readonly Func<uint, (uint tex, int w, int h)> _resolve;
|
||||||
|
|
||||||
|
public Action? OnClick { get; set; }
|
||||||
|
public string? Label { get; set; }
|
||||||
|
public UiDatFont? LabelFont { get; set; }
|
||||||
|
public Vector4 LabelColor { get; set; } = Vector4.One;
|
||||||
|
|
||||||
|
/// <summary>Active state name, runtime-settable (e.g. Max/Min toggling Normal↔Minimized).</summary>
|
||||||
|
public string ActiveState { get; set; } = "";
|
||||||
|
|
||||||
|
public UiButton(ElementInfo info, Func<uint, (uint tex, int w, int h)> resolve)
|
||||||
|
{
|
||||||
|
_info = info;
|
||||||
|
_resolve = resolve;
|
||||||
|
ClickThrough = false; // buttons are interactive
|
||||||
|
if (!string.IsNullOrEmpty(info.DefaultStateName)) ActiveState = info.DefaultStateName;
|
||||||
|
else if (info.StateMedia.ContainsKey("Normal")) ActiveState = "Normal";
|
||||||
|
}
|
||||||
|
|
||||||
|
private uint ActiveFile()
|
||||||
|
=> _info.StateMedia.TryGetValue(ActiveState, out var m) ? m.File
|
||||||
|
: _info.StateMedia.TryGetValue("", out var d) ? d.File : 0u;
|
||||||
|
|
||||||
|
protected override void OnDraw(UiRenderContext ctx)
|
||||||
|
{
|
||||||
|
uint file = ActiveFile();
|
||||||
|
if (file != 0)
|
||||||
|
{
|
||||||
|
var (tex, tw, th) = _resolve(file);
|
||||||
|
if (tex != 0 && tw != 0 && th != 0)
|
||||||
|
ctx.DrawSprite(tex, 0, 0, Width, Height, 0, 0, Width / tw, Height / th, Vector4.One);
|
||||||
|
}
|
||||||
|
if (Label is { Length: > 0 } label && LabelFont is { } lf)
|
||||||
|
{
|
||||||
|
float tx = (Width - lf.MeasureWidth(label)) * 0.5f;
|
||||||
|
float ty = (Height - lf.LineHeight) * 0.5f;
|
||||||
|
ctx.DrawStringDat(lf, label, tx, ty, LabelColor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override bool OnEvent(in UiEvent e)
|
||||||
|
{
|
||||||
|
if (e.Type == UiEventType.Click && OnClick is not null) { OnClick(); return true; }
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the button tests — verify they pass.**
|
||||||
|
|
||||||
|
Run: `dotnet test … --filter "FullyQualifiedName~UiButtonTests"`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Write the failing factory test + register Type 1.**
|
||||||
|
|
||||||
|
In `DatWidgetFactoryTests.cs`:
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public void Type1_Button_MakesUiButton()
|
||||||
|
{
|
||||||
|
var e = DatWidgetFactory.Create(new ElementInfo { Type = 1, Width = 46, Height = 18 }, NoTex, null);
|
||||||
|
Assert.IsType<UiButton>(e);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
In `DatWidgetFactory.Create` switch:
|
||||||
|
```csharp
|
||||||
|
1 => new UiButton(info, resolve), // UIElement_Button (reg :125828)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Update the controller to bind the factory-built buttons.**
|
||||||
|
|
||||||
|
In `ChatWindowController.cs`, the Send block currently does `if (layout.FindElement(SendId) is UiDatElement sendEl) { sendEl.ClickThrough = false; sendEl.OnClick = …; sendEl.Label = "Send"; sendEl.LabelFont = datFont; sendEl.LabelColor = …; }`. Change the cast to `UiButton`:
|
||||||
|
```csharp
|
||||||
|
if (layout.FindElement(SendId) is UiButton sendEl)
|
||||||
|
{
|
||||||
|
sendEl.OnClick = () => c.Input.Submit();
|
||||||
|
sendEl.Label = "Send";
|
||||||
|
sendEl.LabelFont = datFont;
|
||||||
|
sendEl.LabelColor = new Vector4(1f, 0.92f, 0.72f, 1f);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
And the Max/Min block: change `if (layout.FindElement(MaxMinId) is UiDatElement maxMinEl)` → `is UiButton maxMinEl`, drop the now-unneeded `maxMinEl.ClickThrough = false;` (UiButton is interactive by construction), keep the `maxMinEl.Left = track.Left - maxMinEl.Width;` and `maxMinEl.OnClick = c.ToggleMaximize;`.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Build + run the full UI suite.**
|
||||||
|
|
||||||
|
Run: `dotnet build` then `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Commit.**
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
git commit -m "feat(D.2b): UiButton (Type 1) — Send + Max/Min as generic buttons (widget-generalization Task 3)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: `UiMenu` (Type 6) — genericize the channel menu
|
||||||
|
|
||||||
|
`UiChannelMenu` is the one heavy genericization: move `ChatChannelKind`, the 14-item array, the button-text map, and the availability defaults into `ChatWindowController`; keep all drawing/geometry/event mechanics in a generic `UiMenu` keyed on `object? Payload`.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Rename: `src/AcDream.App/UI/UiChannelMenu.cs` → `src/AcDream.App/UI/UiMenu.cs`
|
||||||
|
- Rename: `tests/AcDream.App.Tests/UI/UiChannelMenuTests.cs` → `tests/AcDream.App.Tests/UI/UiMenuTests.cs`
|
||||||
|
- Modify: `DatWidgetFactory.cs`, `ChatWindowController.cs`, `DatWidgetFactoryTests.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Rename file + class.**
|
||||||
|
```bash
|
||||||
|
git mv src/AcDream.App/UI/UiChannelMenu.cs src/AcDream.App/UI/UiMenu.cs
|
||||||
|
git mv tests/AcDream.App.Tests/UI/UiChannelMenuTests.cs tests/AcDream.App.Tests/UI/UiMenuTests.cs
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Replace the chat-specific members with the generic surface.**
|
||||||
|
|
||||||
|
In `UiMenu.cs`, rename `class UiChannelMenu` → `class UiMenu`; remove `using AcDream.UI.Abstractions;`. Replace the chat-specific members — the `Item` record, the static `Items` array, `Selected` (ChatChannelKind), `OnChannelChanged`, `AvailabilityProvider`, `IsAvailable`, and `ButtonText` — with these generic members:
|
||||||
|
```csharp
|
||||||
|
/// <summary>One menu row: its label + an opaque payload the controller maps back.</summary>
|
||||||
|
public readonly record struct MenuItem(string Label, object? Payload);
|
||||||
|
|
||||||
|
/// <summary>The rows, populated by the controller. Laid out column-major:
|
||||||
|
/// rows 0..RowsPerColumn-1 in column 0, then the next group in column 1, etc.</summary>
|
||||||
|
public IReadOnlyList<MenuItem> Items { get; set; } = System.Array.Empty<MenuItem>();
|
||||||
|
|
||||||
|
/// <summary>The currently-selected payload (drives the highlighted row).</summary>
|
||||||
|
public object? Selected { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Fired with the picked item's payload when a row is chosen.</summary>
|
||||||
|
public Action<object?>? OnSelect { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Per-payload enabled gate (disabled rows render greyed + are inert).
|
||||||
|
/// Null ⇒ all rows enabled.</summary>
|
||||||
|
public Func<object?, bool>? EnabledProvider { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Button-face caption (the active target). Null ⇒ blank face.</summary>
|
||||||
|
public Func<string>? ButtonLabelProvider { get; set; }
|
||||||
|
```
|
||||||
|
Make the geometry constants settable so a controller/factory can match the dat:
|
||||||
|
```csharp
|
||||||
|
public int RowsPerColumn { get; set; } = 7; // items per column (dat item template)
|
||||||
|
public float RowHeight { get; set; } = 17f; // dat item template 0x1000001E H=17
|
||||||
|
public float ColumnWidth { get; set; } = 191f; // dat item template W=191
|
||||||
|
```
|
||||||
|
Replace the `private const int Rows`/`ItemH`/`ColW` usages with `RowsPerColumn`/`RowHeight`/`ColumnWidth`, and make the derived sizes instance members:
|
||||||
|
```csharp
|
||||||
|
private int ColumnCount => (Items.Count + RowsPerColumn - 1) / System.Math.Max(1, RowsPerColumn);
|
||||||
|
private float InteriorW => ColumnCount * ColumnWidth;
|
||||||
|
private float InteriorH => RowsPerColumn * RowHeight;
|
||||||
|
private float OuterW => InteriorW + 2 * Border;
|
||||||
|
private float OuterH => InteriorH + 2 * Border;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Genericize the draw/event logic (mechanical swaps).**
|
||||||
|
|
||||||
|
In the same file, in `OnDrawOverlay`, `OnEvent`, `OnHitTest`, and `DrawButtonFace`/label:
|
||||||
|
- Replace `Items[i].Channel is { } c && c == Selected` (selected-row test) with `Equals(Items[i].Payload, Selected)`.
|
||||||
|
- Replace `Items[i].Channel is not { } c || IsAvailable(c)` (availability) with `EnabledProvider?.Invoke(Items[i].Payload) ?? true`.
|
||||||
|
- Replace the button caption `ButtonText` with `ButtonLabelProvider?.Invoke() ?? ""` in both `OnDraw` (the `DrawLabel(ctx, ButtonText, …)` call) and `NaturalButtonWidth()` (the `MeasureWidth(ButtonText)`).
|
||||||
|
- In `OnEvent`'s pick branch, replace the channel-specific selection
|
||||||
|
```csharp
|
||||||
|
if (… && Items[idx].Channel is { } ch && IsAvailable(ch)) { Selected = ch; OnChannelChanged?.Invoke(ch); }
|
||||||
|
```
|
||||||
|
with
|
||||||
|
```csharp
|
||||||
|
if (row >= 0 && row < RowsPerColumn && idx >= 0 && idx < Items.Count
|
||||||
|
&& (EnabledProvider?.Invoke(Items[idx].Payload) ?? true))
|
||||||
|
{
|
||||||
|
Selected = Items[idx].Payload;
|
||||||
|
OnSelect?.Invoke(Selected);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- Replace the column/row math `int col = i / Rows, row = i % Rows;` with `RowsPerColumn` and `Items.Length` → `Items.Count`.
|
||||||
|
Keep `DrawBevel`, `DrawButtonFace`, `DrawSprite`, `DrawLabel`, the sprite-id properties, the colors, and `NaturalButtonWidth()` otherwise unchanged. Update the doc comment to cite `UIElement_Menu (RegisterElementClass(6) @ :120163)` + `MakePopup @0x46d310`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update the menu tests for the generic surface.**
|
||||||
|
|
||||||
|
In `UiMenuTests.cs`, rename the class to `UiMenuTests`, replace `UiChannelMenu` → `UiMenu`. Where tests referenced `ChatChannelKind`/`Selected`/`OnChannelChanged`, rewrite them against the generic surface, e.g.:
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public void ClickingRow_FiresOnSelect_WithPayload()
|
||||||
|
{
|
||||||
|
object? picked = null;
|
||||||
|
var m = new UiMenu
|
||||||
|
{
|
||||||
|
Width = 46, Height = 18,
|
||||||
|
Items = new UiMenu.MenuItem[] { new("Chat to All", "say"), new("Trade", "trade") },
|
||||||
|
OnSelect = p => picked = p,
|
||||||
|
};
|
||||||
|
// open, then click row 0 (geometry per RowsPerColumn/RowHeight — mirror the
|
||||||
|
// existing test's click coords, which used the same 17px rows).
|
||||||
|
m.OnEvent(new UiEvent(UiEventType.MouseDown, 0, 0, 0)); // toggle open
|
||||||
|
// … click into row 0 of the open popup (reuse the prior test's local coords) …
|
||||||
|
Assert.Equal("say", picked);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
> Reuse the exact open/click coordinates from the original `UiChannelMenuTests` (they map into the same popup geometry); only the payload/selection assertions change.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run the menu tests — green.**
|
||||||
|
|
||||||
|
Run: `dotnet test … --filter "FullyQualifiedName~UiMenuTests"`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Failing factory test + register Type 6.**
|
||||||
|
|
||||||
|
In `DatWidgetFactoryTests.cs`:
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public void Type6_Menu_MakesUiMenu()
|
||||||
|
{
|
||||||
|
var e = DatWidgetFactory.Create(new ElementInfo { Type = 6, Width = 46, Height = 18 }, NoTex, null);
|
||||||
|
Assert.IsType<UiMenu>(e);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
In `DatWidgetFactory.Create` switch:
|
||||||
|
```csharp
|
||||||
|
6 => new UiMenu(), // UIElement_Menu (reg :120163)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Move the channel knowledge into `ChatWindowController`.**
|
||||||
|
|
||||||
|
In `ChatWindowController.cs`, add the channel item table + maps (ported verbatim from the old `UiChannelMenu`):
|
||||||
|
```csharp
|
||||||
|
// Talk-focus channels (ported from the old UiChannelMenu — gmMainChatUI::InitTalkFocusMenu @0x4cdc50).
|
||||||
|
private static readonly (string Label, ChatChannelKind? Channel)[] ChannelItems =
|
||||||
|
{
|
||||||
|
("Squelch (ignore)", null),
|
||||||
|
("Tell to Selected", null),
|
||||||
|
("Chat to All", ChatChannelKind.Say),
|
||||||
|
("Tell to Fellows", ChatChannelKind.Fellowship),
|
||||||
|
("Tell to General Chat", ChatChannelKind.General),
|
||||||
|
("Tell to LFG Chat", ChatChannelKind.Lfg),
|
||||||
|
("Tell to Society Chat", ChatChannelKind.Society),
|
||||||
|
("Tell to Monarch", ChatChannelKind.Monarch),
|
||||||
|
("Tell to Patron", ChatChannelKind.Patron),
|
||||||
|
("Tell to Vassals", ChatChannelKind.Vassals),
|
||||||
|
("Tell to Allegiance", ChatChannelKind.Allegiance),
|
||||||
|
("Tell to Trade Chat", ChatChannelKind.Trade),
|
||||||
|
("Tell to Roleplay Chat", ChatChannelKind.Roleplay),
|
||||||
|
("Tell to Olthoi Chat", ChatChannelKind.Olthoi),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string ChannelButtonLabel(ChatChannelKind k) => k switch
|
||||||
|
{
|
||||||
|
ChatChannelKind.Say => "Chat", ChatChannelKind.General => "General",
|
||||||
|
ChatChannelKind.Trade => "Trade", ChatChannelKind.Lfg => "LFG",
|
||||||
|
ChatChannelKind.Fellowship => "Fellow", ChatChannelKind.Allegiance => "Alleg",
|
||||||
|
ChatChannelKind.Patron => "Patron", ChatChannelKind.Vassals => "Vassals",
|
||||||
|
ChatChannelKind.Monarch => "Monarch", ChatChannelKind.Roleplay => "Roleplay",
|
||||||
|
ChatChannelKind.Society => "Society", ChatChannelKind.Olthoi => "Olthoi",
|
||||||
|
_ => "Chat",
|
||||||
|
};
|
||||||
|
|
||||||
|
private static bool ChannelAvailable(ChatChannelKind k)
|
||||||
|
=> k is ChatChannelKind.Say or ChatChannelKind.General or ChatChannelKind.Trade or ChatChannelKind.Lfg;
|
||||||
|
```
|
||||||
|
Replace the "Channel menu — replace the imported menu placeholder" block. The factory now builds the Type-6 element as a `UiMenu`; find it and populate it:
|
||||||
|
```csharp
|
||||||
|
if (layout.FindElement(MenuId) is UiMenu menu)
|
||||||
|
{
|
||||||
|
menu.DatFont = datFont; menu.Font = debugFont; menu.SpriteResolve = resolve;
|
||||||
|
menu.NormalSprite = MenuNormal; menu.PressedSprite = MenuPressed;
|
||||||
|
menu.PopupBgSprite = MenuPopupBg;
|
||||||
|
menu.ItemNormalSprite = MenuItemRow; menu.ItemHighlightSprite = MenuItemSelected;
|
||||||
|
menu.Items = System.Array.ConvertAll(ChannelItems,
|
||||||
|
t => new UiMenu.MenuItem(t.Label, (object?)t.Channel));
|
||||||
|
menu.Selected = (object?)c._activeChannel;
|
||||||
|
menu.EnabledProvider = p => p is not ChatChannelKind ch || ChannelAvailable(ch);
|
||||||
|
menu.ButtonLabelProvider = () => ChannelButtonLabel(c._activeChannel);
|
||||||
|
menu.OnSelect = p =>
|
||||||
|
{
|
||||||
|
if (p is ChatChannelKind ch) { c._activeChannel = ch; menu.Selected = p; }
|
||||||
|
};
|
||||||
|
c.Menu = menu;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Update the `Menu` property type: `public UiMenu Menu { get; private set; } = null!;` Update the reflow block (`ReflowInputRow`) — it calls `c.Menu.NaturalButtonWidth()`, `c.Menu.ResetAnchorCapture()` (both still exist on `UiMenu`), and wraps `c.Menu.OnChannelChanged`. Replace the `OnChannelChanged` wrap with the generic `OnSelect`:
|
||||||
|
```csharp
|
||||||
|
var onSelect = c.Menu.OnSelect;
|
||||||
|
c.Menu.OnSelect = p => { onSelect?.Invoke(p); ReflowInputRow(); };
|
||||||
|
```
|
||||||
|
> `_activeChannel` already exists on the controller; the old per-menu `OnChannelChanged = k => c._activeChannel = k;` is now folded into `OnSelect`.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Build + run the full UI suite.**
|
||||||
|
|
||||||
|
Run: `dotnet build` then `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 9: Add a divergence row if the generic menu lost fidelity.**
|
||||||
|
|
||||||
|
The generic `UiMenu` item model is flat (label+payload, no submenu/hierarchical popup). If that is a new approximation vs `UIElement_Menu::MakePopup`'s nested popups, add a row to `docs/architecture/retail-divergence-register.md` (Adaptation) citing `src/AcDream.App/UI/UiMenu.cs` + `MakePopup @0x46d310`. (The chat menu is single-level, so this is a latent note, not a behavior change.)
|
||||||
|
|
||||||
|
- [ ] **Step 10: Commit.**
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
git commit -m "feat(D.2b): UiMenu (Type 6) — generic dropdown; channel knowledge moves to controller (widget-generalization Task 4)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: `UiText` (Type 12) — transcript + the Type-12 flip
|
||||||
|
|
||||||
|
Rename `UiChatView` → `UiText`, default its background to transparent + add an optional dat state-sprite background (so any Type-12-with-sprite element keeps rendering its sprite), register Type 12, flip the two factory Type-12 tests, and have the controller bind the factory-built transcript. An **unbound `UiText` must draw nothing** so vitals stays frozen.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Rename: `src/AcDream.App/UI/UiChatView.cs` → `src/AcDream.App/UI/UiText.cs`
|
||||||
|
- Rename: `tests/AcDream.App.Tests/UI/UiChatViewTests.cs` → `UiTextTests.cs`; `UiChatViewDatFontTests.cs` → `UiTextDatFontTests.cs`
|
||||||
|
- Modify: `DatWidgetFactory.cs`, `LayoutImporter.cs` (none needed — Text recurses normally), `ChatWindowController.cs`, `DatWidgetFactoryTests.cs`, `GameWindow.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Rename file + class + tests.**
|
||||||
|
```bash
|
||||||
|
git mv src/AcDream.App/UI/UiChatView.cs src/AcDream.App/UI/UiText.cs
|
||||||
|
git mv tests/AcDream.App.Tests/UI/UiChatViewTests.cs tests/AcDream.App.Tests/UI/UiTextTests.cs
|
||||||
|
git mv tests/AcDream.App.Tests/UI/UiChatViewDatFontTests.cs tests/AcDream.App.Tests/UI/UiTextDatFontTests.cs
|
||||||
|
```
|
||||||
|
In `UiText.cs`: rename `class UiChatView` → `class UiText`; the nested `Line`/`Pos` records, `LinesProvider`, selection, and scroll stay. Update the doc to cite `UIElement_Text (RegisterElementClass(0xc) @ :115655)`. In the test files, rename classes + replace `UiChatView` → `UiText`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Default the background to transparent (so an unbound UiText is invisible).**
|
||||||
|
|
||||||
|
In `UiText.cs`, change:
|
||||||
|
```csharp
|
||||||
|
public Vector4 BackgroundColor { get; set; } = new(0f, 0f, 0f, 0f); // transparent by default
|
||||||
|
```
|
||||||
|
(was `(0,0,0,0.35)`). `OnDraw`'s `ctx.DrawFill(0,0,Width,Height,BackgroundColor)` then draws nothing when transparent. The chat controller will set the translucent value explicitly (Step 6).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add an optional dat state-sprite background (faithful UIElement_Text media).**
|
||||||
|
|
||||||
|
So a Type-12 element that carries its own sprite (currently rendered by `UiDatElement`) does not lose it. Add to `UiText`:
|
||||||
|
```csharp
|
||||||
|
/// <summary>Optional dat state-sprite background (the element's own media), drawn
|
||||||
|
/// UNDER the text. Set by DatWidgetFactory.BuildText from the ElementInfo. 0 = none.</summary>
|
||||||
|
public uint BackgroundSprite { get; set; }
|
||||||
|
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
|
||||||
|
```
|
||||||
|
At the very top of `OnDraw`, before `DrawFill`:
|
||||||
|
```csharp
|
||||||
|
if (BackgroundSprite != 0 && SpriteResolve is { } sr)
|
||||||
|
{
|
||||||
|
var (tex, tw, th) = sr(BackgroundSprite);
|
||||||
|
if (tex != 0 && tw != 0 && th != 0)
|
||||||
|
ctx.DrawSprite(tex, 0, 0, Width, Height, 0, 0, Width / tw, Height / th, Vector4.One);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Write the failing factory test (and flip the two existing Type-12 tests).**
|
||||||
|
|
||||||
|
In `DatWidgetFactoryTests.cs`:
|
||||||
|
- Add:
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public void Type12_Text_MakesUiText()
|
||||||
|
{
|
||||||
|
var e = DatWidgetFactory.Create(new ElementInfo { Type = 12, Width = 100, Height = 40 }, NoTex, null);
|
||||||
|
Assert.IsType<UiText>(e);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- Replace `Type12_StylePrototype_ReturnsNull` (delete it — Type 12 is no longer skipped).
|
||||||
|
- Replace `DatWidgetFactory_Type12WithMedia_Renders` body to assert `UiText` for both media and no-media:
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public void DatWidgetFactory_Type12_AlwaysMakesUiText()
|
||||||
|
{
|
||||||
|
var withMedia = new ElementInfo { Type = 12, Width = 32, Height = 16,
|
||||||
|
StateMedia = { ["Normal"] = (0x00001234u, 1) } };
|
||||||
|
Assert.IsType<UiText>(DatWidgetFactory.Create(withMedia, NoTex, null));
|
||||||
|
Assert.IsType<UiText>(DatWidgetFactory.Create(new ElementInfo { Type = 12 }, NoTex, null));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run — verify the new/flipped tests fail.**
|
||||||
|
|
||||||
|
Run: `dotnet test … --filter "FullyQualifiedName~DatWidgetFactoryTests"`
|
||||||
|
Expected: FAIL on the Type-12 asserts (factory still returns null / UiDatElement).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Register Type 12 + add `BuildText`; remove the skip.**
|
||||||
|
|
||||||
|
In `DatWidgetFactory.cs`:
|
||||||
|
- Delete the skip line `if (info.Type == 12 && info.StateMedia.Count == 0) return null;`.
|
||||||
|
- Add to the switch:
|
||||||
|
```csharp
|
||||||
|
12 => BuildText(info, resolve), // UIElement_Text (reg :115655)
|
||||||
|
```
|
||||||
|
- Add the builder:
|
||||||
|
```csharp
|
||||||
|
/// <summary>Type-12 UIElement_Text: a scrollable colored-line text view. The
|
||||||
|
/// element's own Direct/Normal media (if any) becomes the background sprite, drawn
|
||||||
|
/// under the text — so a Type-12 element that previously rendered via UiDatElement
|
||||||
|
/// keeps its sprite. Lines are bound later by the controller (LinesProvider).</summary>
|
||||||
|
private static UiText BuildText(ElementInfo info, Func<uint, (uint, int, int)> resolve)
|
||||||
|
{
|
||||||
|
uint bg = info.StateMedia.TryGetValue(
|
||||||
|
!string.IsNullOrEmpty(info.DefaultStateName) ? info.DefaultStateName
|
||||||
|
: info.StateMedia.ContainsKey("Normal") ? "Normal" : "", out var m)
|
||||||
|
? m.File : 0u;
|
||||||
|
return new UiText { BackgroundSprite = bg, SpriteResolve = resolve };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
> Update the `Create` summary/`<returns>` doc that referenced Type-12 returning null.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Verify factory + vitals fixture still green (vitals frozen).**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~DatWidgetFactoryTests|FullyQualifiedName~LayoutConformanceTests|FullyQualifiedName~VitalsBindingTests"`
|
||||||
|
Expected: PASS. The vitals number text elements are meter-children (consumed, never built — `LayoutImporter.cs:113`), and any other vitals Type-12 element now builds as an unbound, transparent `UiText` (draws only its own sprite, if it had one — same as before). **Spec verification #2:** if a vitals conformance test fails, a standalone Type-12 element changed class — inspect it; its sprite must still draw via `BackgroundSprite`.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Controller binds the factory-built transcript (instead of constructing it).**
|
||||||
|
|
||||||
|
In `ChatWindowController.cs`, the factory now builds the Type-12 transcript element `0x10000011` as a `UiText`. Replace the "Transcript" block (which read `tInfo` and `new UiChatView { … }; transcriptPanel.AddChild(...)`) with find-and-bind:
|
||||||
|
```csharp
|
||||||
|
// The factory built the Type-12 transcript as a UiText; find + bind it.
|
||||||
|
c.Transcript = layout.FindElement(TranscriptId) as UiText
|
||||||
|
?? throw new InvalidOperationException("chat transcript 0x10000011 not built as UiText");
|
||||||
|
c.Transcript.DatFont = datFont;
|
||||||
|
c.Transcript.Font = debugFont;
|
||||||
|
c.Transcript.BackgroundColor = new Vector4(0f, 0f, 0f, 0.35f); // retail translucent transcript
|
||||||
|
c.Transcript.LinesProvider = () => BuildLines(vm, c.Transcript, datFont, debugFont);
|
||||||
|
```
|
||||||
|
Change the `Transcript` property type to `public UiText Transcript { get; private set; } = null!;`. Remove the now-unused `tInfo` lookup + the `transcriptPanel.AddChild` (the transcript is already in the tree at its dat position). Keep the `transcriptPanel.Top/Height` resize-bar reclaim.
|
||||||
|
|
||||||
|
Also in `ChatWindowController.cs`, replace **every** `UiChatView.Line` with `UiText.Line` — this hits `BuildLines` (its `UiText view` parameter, its `IReadOnlyList<UiText.Line>` return type, the `Array.Empty<UiText.Line>()`, and the `new UiText.Line(frag, color)` inside the wrap loop). `WrapText`/`RetailChatColor` are unaffected (they return `string`/`Vector4`).
|
||||||
|
|
||||||
|
Finally, repoint the `Bind` early-guard: it currently does `var tInfo = FindInfo(rootInfo, TranscriptId);` and checks `tInfo is null`. The transcript is now found via `layout.FindElement(TranscriptId)`; change the guard to null-check the factory-built widgets it needs (`layout.FindElement(TranscriptPanelId)` for the panel, plus the transcript/input found in their Steps). The `iInfo` lookup stays only for Task 6 Variant B. (Full guard tidy lands in Task 7.)
|
||||||
|
|
||||||
|
- [ ] **Step 9: GameWindow follow-through.**
|
||||||
|
|
||||||
|
`GameWindow.cs:1860` (`chatController.Transcript.Keyboard = …`) still compiles (`UiText.Keyboard` exists). Build to confirm.
|
||||||
|
|
||||||
|
- [ ] **Step 10: Build + full UI suite.**
|
||||||
|
|
||||||
|
Run: `dotnet build` then `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 11: Amend AP-37 (Type-0 text skip retired).**
|
||||||
|
|
||||||
|
In `docs/architecture/retail-divergence-register.md`, edit AP-37: remove the "Standalone Type-0 text elements are also skipped (… a dedicated dat-text widget is Plan 2)" clause (now shipped as `UiText`). Keep the meter-collapse clause and the vitals-numbers-via-`UiMeter.Label` clause (retired in Task 8).
|
||||||
|
|
||||||
|
- [ ] **Step 12: Commit.**
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
git commit -m "feat(D.2b): UiText (Type 12) — generic text + Type-12 flip; transcript factory-built (widget-generalization Task 5)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: `UiField` (Type 3) — editable input
|
||||||
|
|
||||||
|
Rename `UiChatInput` → `UiField`, register Type 3, and wire the input. **Input handling depends on Task 1 Step 5's recorded resolved Type** for `0x10000016`:
|
||||||
|
- **If it resolved to Type 3:** the factory builds `UiField` directly; the controller finds + binds it.
|
||||||
|
- **If it resolved to Type 12** (per the Map trace): the factory built it as a `UiText`; the controller *replaces* it with a `UiField` at the same rect (the existing replace pattern).
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Rename: `src/AcDream.App/UI/UiChatInput.cs` → `src/AcDream.App/UI/UiField.cs`; `UiChatInputTests.cs` → `UiFieldTests.cs`
|
||||||
|
- Modify: `DatWidgetFactory.cs`, `ChatWindowController.cs`, `DatWidgetFactoryTests.cs`, `GameWindow.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Confirm the input's resolved Type from Task 1, choose the path.**
|
||||||
|
|
||||||
|
Re-read `ChatLayoutConformanceTests.ChatFixture_ResolvedTypes_MatchRetailRegistry` (Task 1) for `0x10000016`. Note "Type 3 → direct build" or "Type 12 → controller-place". Proceed with the matching variant in Step 6.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Rename file + class + tests.**
|
||||||
|
```bash
|
||||||
|
git mv src/AcDream.App/UI/UiChatInput.cs src/AcDream.App/UI/UiField.cs
|
||||||
|
git mv tests/AcDream.App.Tests/UI/UiChatInputTests.cs tests/AcDream.App.Tests/UI/UiFieldTests.cs
|
||||||
|
```
|
||||||
|
In `UiField.cs`: rename `class UiChatInput` → `class UiField`; body unchanged. Update doc to cite `UIElement_Field (RegisterElementClass(3) @ :126190)` + the drag-drop hooks (`CatchDroppedItem`/`MouseOverTop`) it will host for future item windows. In `UiFieldTests.cs`: rename class, replace `UiChatInput` → `UiField`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Default the background to transparent (consistency with UiText).**
|
||||||
|
|
||||||
|
Change `UiField.BackgroundColor` default to `new(0f, 0f, 0f, 0f)`. The controller sets the translucent value (Step 6).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Failing factory test + register Type 3.**
|
||||||
|
|
||||||
|
In `DatWidgetFactoryTests.cs`:
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public void Type3_Field_MakesUiField()
|
||||||
|
{
|
||||||
|
var e = DatWidgetFactory.Create(new ElementInfo { Type = 3, Width = 200, Height = 16 }, NoTex, null);
|
||||||
|
Assert.IsType<UiField>(e);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
In `DatWidgetFactory.Create` switch:
|
||||||
|
```csharp
|
||||||
|
3 => new UiField(), // UIElement_Field (reg :126190)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run — verify pass.**
|
||||||
|
|
||||||
|
Run: `dotnet test … --filter "FullyQualifiedName~DatWidgetFactoryTests.Type3_Field_MakesUiField|FullyQualifiedName~UiFieldTests"`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Wire the input in the controller (variant per Step 1).**
|
||||||
|
|
||||||
|
Replace the "Input" block (`new UiChatInput { … }; inputBar.AddChild(c.Input); c.Input.OnSubmit = …`).
|
||||||
|
|
||||||
|
**Variant A — input resolved to Type 3 (factory-built):**
|
||||||
|
```csharp
|
||||||
|
c.Input = layout.FindElement(InputId) as UiField
|
||||||
|
?? throw new InvalidOperationException("chat input 0x10000016 not built as UiField");
|
||||||
|
c.Input.DatFont = datFont; c.Input.Font = debugFont;
|
||||||
|
c.Input.BackgroundColor = new Vector4(0f, 0f, 0f, 0.35f);
|
||||||
|
c.Input.SpriteResolve = resolve; c.Input.FocusFieldSprite = InputFocusField;
|
||||||
|
c.Input.OnSubmit = text => ChatCommandRouter.Submit(text, vm, busProvider(), c._activeChannel);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Variant B — input resolved to Type 12 (controller-placed UiField over the UiText):**
|
||||||
|
```csharp
|
||||||
|
// 0x10000016 resolves to Type-12 Text in this layout; the editable entry is a
|
||||||
|
// controller-placed UiField at the dat element's rect (retail authors a separate Field).
|
||||||
|
var iInfo = FindInfo(rootInfo, InputId)
|
||||||
|
?? throw new InvalidOperationException("chat input info 0x10000016 missing");
|
||||||
|
if (layout.FindElement(InputId) is { Parent: { } iparent } placeholder)
|
||||||
|
iparent.RemoveChild(placeholder); // drop the read-only Text placeholder
|
||||||
|
c.Input = new UiField
|
||||||
|
{
|
||||||
|
Left = iInfo.X, Top = iInfo.Y, Width = iInfo.Width, Height = iInfo.Height,
|
||||||
|
Anchors = ElementReader.ToAnchors(iInfo.Left, iInfo.Top, iInfo.Right, iInfo.Bottom),
|
||||||
|
DatFont = datFont, Font = debugFont,
|
||||||
|
BackgroundColor = new Vector4(0f, 0f, 0f, 0.35f),
|
||||||
|
SpriteResolve = resolve, FocusFieldSprite = InputFocusField,
|
||||||
|
};
|
||||||
|
(inputBar).AddChild(c.Input);
|
||||||
|
c.Input.OnSubmit = text => ChatCommandRouter.Submit(text, vm, busProvider(), c._activeChannel);
|
||||||
|
```
|
||||||
|
Change the `Input` property type to `public UiField Input { get; private set; } = null!;` (Keep `FindInfo` for Variant B; it may become unused in Variant A — remove it then.)
|
||||||
|
|
||||||
|
- [ ] **Step 7: GameWindow follow-through.**
|
||||||
|
|
||||||
|
`GameWindow.cs:1861` (`chatController.Input.Keyboard = …`) still compiles (`UiField.Keyboard` exists). Build to confirm.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Build + full UI suite.**
|
||||||
|
|
||||||
|
Run: `dotnet build` then `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 9: Commit.**
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
git commit -m "feat(D.2b): UiField (Type 3) — editable input as a generic field (widget-generalization Task 6)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Thin + verify the controller; remove dead construction
|
||||||
|
|
||||||
|
After Tasks 2–6, `ChatWindowController.Bind` should construct no widgets (except the Variant-B input). Audit and tidy.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/AcDream.App/UI/Layout/ChatWindowController.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Remove dead helpers + confirm find-by-id shape.**
|
||||||
|
|
||||||
|
In `ChatWindowController.cs`: confirm every widget is obtained via `layout.FindElement(id) as UiX` and only data/callbacks are bound. Remove any now-unused locals (`transcriptPanel`/`inputBar` are still used for the resize-bar reclaim / Variant-B parent — keep those; remove `tInfo`/`FindInfo` if Variant A). Confirm the class doc reads as the `gmMainChatUI::PostInit @0x4ce130` analogue (find child by id → bind).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update `ChatWindowControllerTests` for the new types.**
|
||||||
|
|
||||||
|
In `tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs`, update any references to `UiChatView`/`UiChatInput`/`UiChatScrollbar`/`UiChannelMenu` to `UiText`/`UiField`/`UiScrollbar`/`UiMenu`, and any assertions on `.Selected`/`OnChannelChanged` to the generic `OnSelect`/payload surface. Run them to confirm the binding still wires the right elements.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Build + full UI suite.**
|
||||||
|
|
||||||
|
Run: `dotnet build` then `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Visual gate (user) — chat unchanged.**
|
||||||
|
|
||||||
|
Launch the client (`ACDREAM_RETAIL_UI=1`, per CLAUDE.md launch recipe) and confirm the chat window looks + behaves identically to before this pass: transcript scroll/select/copy, input write-mode/history/clipboard, channel dropdown, send, max/min, scrollbar drag. **Stop for user confirmation.**
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit.**
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
git commit -m "refactor(D.2b): ChatWindowController is now a thin find-by-id binder (widget-generalization Task 7)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8 (GATED): vitals numbers as `UiText`
|
||||||
|
|
||||||
|
Rewire the vitals number text from `UiMeter.Label` to factory-built `UiText` (retail-faithful: vitals numbers are `UIElement_Text`). **This is a stop-and-confirm gate** — vitals shipped pixel-identical and is fixture-locked. If it risks the pixel-identical result, **stop and keep `UiMeter.Label`** (narrow AP-37 instead).
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/AcDream.App/UI/Layout/VitalsController.cs`, `LayoutImporter.cs` (meter child handling), `GameWindow.cs` (Bind call), `tests/.../VitalsBindingTests.cs`, `fixtures/vitals_2100006C.json`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Decide the number element's path.**
|
||||||
|
|
||||||
|
The vitals number text is a **meter child** (consumed; `LayoutImporter.cs:113` does not recurse meter children). To render it as a real `UiText`, either (a) have `VitalsController` construct a `UiText` at the number element's rect (read from the meter's children — mirrors the chat Variant-B pattern), or (b) stop consuming the meter's text child so the factory builds it. **Prefer (a)** — it is local to `VitalsController` and does not disturb the meter slice extraction. Read the number element's rect from `DatWidgetFactory.BuildMeter`'s skipped text child (expose it, or re-read via the layout's `ElementInfo`).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write a failing binding test.**
|
||||||
|
|
||||||
|
In `VitalsBindingTests.cs`, add a test that, after `VitalsController.Bind`, a `UiText` exists for each vital and its `LinesProvider` returns the cur/max string. (Use the vitals fixture; assert the text node is present + bound.)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement the `UiText` number binding in `VitalsController`.**
|
||||||
|
|
||||||
|
Add a `UiText` per meter (constructed at the number rect, single centered line). Keep `UiMeter.Label` unset for vitals. Bind `LinesProvider = () => new[] { new UiText.Line(text(), color) }` (centered — add a `UiText.CenterSingleLine` option or a thin overload if needed for horizontal centering).
|
||||||
|
> If centering a single line requires new `UiText` layout support, add a minimal `public bool CenterHorizontally` flag to `UiText` with a unit test, rather than overloading the chat path.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Build + run vitals tests.**
|
||||||
|
|
||||||
|
Run: `dotnet test … --filter "FullyQualifiedName~VitalsBindingTests|FullyQualifiedName~LayoutConformanceTests"`
|
||||||
|
Expected: PASS. Update `vitals_2100006C.json` only if the resolved tree legitimately changed (it should not — the change is in binding, not the tree).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Visual gate (user) — vitals pixel-identical.**
|
||||||
|
|
||||||
|
Launch (`ACDREAM_RETAIL_UI=1`); confirm the vitals numbers render identically (font, position, centering, color) to the shipped `UiMeter.Label` version. **Stop for user confirmation. If not identical → revert this task and narrow AP-37 instead.**
|
||||||
|
|
||||||
|
- [ ] **Step 6: Retire/narrow AP-37 + update memory.**
|
||||||
|
|
||||||
|
If the rewire lands: in `docs/architecture/retail-divergence-register.md`, retire the AP-37 vitals-numbers clause (now real `UiText`). Update `claude-memory/project_d2b_retail_ui.md` (the generalization pass shipped) + the roadmap.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit.**
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
git commit -m "feat(D.2b): vitals numbers as UiText (widget-generalization Task 8, gated)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Done criteria (from spec §8)
|
||||||
|
|
||||||
|
- [ ] `DatWidgetFactory` registers Types 1, 3, 6, 11, 12 (+ 7) → generic widgets; `_` still → `UiDatElement`.
|
||||||
|
- [ ] The `Type==12 → null` skip is removed; no Type-12 element is double-built (fixtures green).
|
||||||
|
- [ ] No `ChatChannelKind`/chat-color/command-routing knowledge inside any widget; `ChatWindowController` only finds-by-id and binds.
|
||||||
|
- [ ] Chat window visually + behaviorally identical through Tasks 2–7 (user-confirmed, Task 7 Step 4).
|
||||||
|
- [ ] `chat_21000006.json` golden fixture + renamed generic-widget tests all green.
|
||||||
|
- [ ] Vitals window unchanged after Task 8 (user-confirmed), or Task 8 deferred with AP-37 narrowed.
|
||||||
|
- [ ] Every generic widget cites its retail `UIElement_X` class + reg. line.
|
||||||
|
- [ ] Divergence register updated (AP-37 amended; AP-41 re-checked) in the same commits.
|
||||||
|
- [ ] Roadmap / `claude-memory/project_d2b_retail_ui.md` updated when the pass lands.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue