docs(D.2b): chat-window re-drive implementation plan (8 tasks A-H) TDD task breakdown for the data-driven chat window: ChatCommandRouter extraction (A), UiChatView dat-font (B), UiScrollable + wire-in (C/C2), UiChatScrollbar (D), UiChatInput (E), UiChannelMenu (F), ChatWindowController bind/route (G), GameWindow cutover + divergence rows (H). Each ported widget cites its retail class::method. Plan: docs/superpowers/plans/2026-06-15-chat-window-redrive.md Spec: docs/superpowers/specs/2026-06-15-chat-window-redrive-design.md Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> @
1484 lines
58 KiB
Markdown
1484 lines
58 KiB
Markdown
# Chat-window re-drive 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:** Replace the hand-authored retail chat window with a data-driven one built from dat `LayoutDesc 0x21000006` (`gmMainChatUI`), with faithful behavioral widgets ported from the named retail decomp and the dat font.
|
||
|
||
**Architecture:** The existing `LayoutImporter` builds the generic frame (bg sprites, resize bar, grip chrome, tabs, send button) from the dat. A new `ChatWindowController` (the `ChatInterface`/`gmMainChatUI::PostInit` analogue) binds behavior by element id: it swaps the transcript/input placeholder nodes for new behavioral widgets, wires the scrollbar/menu/send/max-min, and routes inbound chat (from `ChatVM`) and outbound (through a shared `ChatCommandRouter`). New widgets port `UIElement_Text`/`_Scrollable`/`_Scrollbar`/`_Menu`.
|
||
|
||
**Tech Stack:** C# / .NET 10, Silk.NET (GL), the in-tree retained-mode UI toolkit (`src/AcDream.App/UI/`), `DatReaderWriter` (dat reads), xUnit (`tests/`).
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-06-15-chat-window-redrive-design.md` — read it first. It has the element→role map, decomp citations, and the divergence rows. The decomp is `docs/research/named-retail/acclient_2013_pseudo_c.txt`.
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
**Create:**
|
||
- `src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs` — shared submit pipeline (client-command intercept → unknown-verb guard → `ChatInputParser.Parse` → `Publish(SendChatCmd)`). Pure, no GL.
|
||
- `src/AcDream.App/UI/UiScrollable.cs` — pixel-scroll coordinator (ports `UIElement_Scrollable` math). Pure, no GL.
|
||
- `src/AcDream.App/UI/UiChatInput.cs` — editable one-line text widget (ports `UIElement_Text` edit path).
|
||
- `src/AcDream.App/UI/UiChatScrollbar.cs` — right-side scrollbar widget (track + thumb + up/down) driving a `UiScrollable`.
|
||
- `src/AcDream.App/UI/UiChannelMenu.cs` — channel-selector dropdown (ports `UIElement_Menu`).
|
||
- `src/AcDream.App/UI/Layout/ChatWindowController.cs` — import + bind-by-id + route (the `ChatInterface`/`gmMainChatUI` analogue).
|
||
- Tests: `tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs`,
|
||
`tests/AcDream.App.Tests/UI/UiScrollableTests.cs`,
|
||
`tests/AcDream.App.Tests/UI/UiChatInputTests.cs`,
|
||
`tests/AcDream.App.Tests/UI/UiChatViewDatFontTests.cs`.
|
||
|
||
**Modify:**
|
||
- `src/AcDream.App/UI/UiChatView.cs` — add `UiDatFont? DatFont`; dat-font measure/advance/draw; wheel = 1 line/notch; `UiScrollable` integration.
|
||
- `src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs` — call `ChatCommandRouter` instead of the inline submit block.
|
||
- `src/AcDream.App/Rendering/GameWindow.cs` — replace the hand-authored chat block (~line 1836) with `ChatWindowController`.
|
||
- `docs/architecture/retail-divergence-register.md` — add the 6 deferral rows.
|
||
- `docs/plans/2026-04-11-roadmap.md` — mark the chat re-drive landed.
|
||
|
||
---
|
||
|
||
## Task A: `ChatCommandRouter` (shared submit pipeline)
|
||
|
||
Extract the submit + client-command logic from `ChatPanel` so both the ImGui chat and the retail chat dispatch identically. `ChatPanel` currently hardcodes `ChatChannelKind.Say`; the router parameterizes the default channel (the retail chat passes the channel-menu selection).
|
||
|
||
**Files:**
|
||
- Create: `src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs`
|
||
- Test: `tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs`
|
||
- Modify: `src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs` (call the router)
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
Create `tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs`:
|
||
|
||
```csharp
|
||
using AcDream.Core.Chat;
|
||
using AcDream.UI.Abstractions;
|
||
using AcDream.UI.Abstractions.Panels.Chat;
|
||
using Xunit;
|
||
|
||
namespace AcDream.UI.Abstractions.Tests.Panels.Chat;
|
||
|
||
public class ChatCommandRouterTests
|
||
{
|
||
// Minimal in-memory command bus capturing the last published SendChatCmd.
|
||
private sealed class CaptureBus : ICommandBus
|
||
{
|
||
public SendChatCmd? Last;
|
||
public void Publish<T>(T command) where T : notnull
|
||
{
|
||
if (command is SendChatCmd c) Last = c;
|
||
}
|
||
}
|
||
|
||
private static (ChatVM vm, ChatLog log, CaptureBus bus) Fixture()
|
||
{
|
||
var log = new ChatLog();
|
||
var vm = new ChatVM(log, displayLimit: 50);
|
||
return (vm, log, new CaptureBus());
|
||
}
|
||
|
||
[Fact]
|
||
public void PlainText_PublishesOnDefaultChannel()
|
||
{
|
||
var (vm, _, bus) = Fixture();
|
||
var outcome = ChatCommandRouter.Submit("hello there", vm, bus, ChatChannelKind.Say);
|
||
Assert.Equal(SubmitOutcome.Sent, outcome);
|
||
Assert.NotNull(bus.Last);
|
||
Assert.Equal(ChatChannelKind.Say, bus.Last!.Channel);
|
||
Assert.Equal("hello there", bus.Last.Text);
|
||
}
|
||
|
||
[Fact]
|
||
public void DefaultChannel_IsHonored()
|
||
{
|
||
var (vm, _, bus) = Fixture();
|
||
ChatCommandRouter.Submit("hi", vm, bus, ChatChannelKind.Fellowship);
|
||
Assert.Equal(ChatChannelKind.Fellowship, bus.Last!.Channel);
|
||
}
|
||
|
||
[Fact]
|
||
public void ClearCommand_DrainsLog_DoesNotPublish()
|
||
{
|
||
var (vm, log, bus) = Fixture();
|
||
log.OnSystemMessage("x", 0);
|
||
var outcome = ChatCommandRouter.Submit("/clear", vm, bus, ChatChannelKind.Say);
|
||
Assert.Equal(SubmitOutcome.ClientHandled, outcome);
|
||
Assert.Null(bus.Last);
|
||
Assert.Empty(log.Snapshot());
|
||
}
|
||
|
||
[Fact]
|
||
public void UnknownSlashVerb_ShowsSystemMessage_DoesNotPublish()
|
||
{
|
||
var (vm, log, bus) = Fixture();
|
||
var outcome = ChatCommandRouter.Submit("/notacommand", vm, bus, ChatChannelKind.Say);
|
||
Assert.Equal(SubmitOutcome.UnknownCommand, outcome);
|
||
Assert.Null(bus.Last);
|
||
Assert.Contains(log.Snapshot(), e => e.Text.Contains("Unknown command"));
|
||
}
|
||
|
||
[Fact]
|
||
public void EmptyInput_DoesNothing()
|
||
{
|
||
var (vm, _, bus) = Fixture();
|
||
var outcome = ChatCommandRouter.Submit(" ", vm, bus, ChatChannelKind.Say);
|
||
Assert.Equal(SubmitOutcome.Empty, outcome);
|
||
Assert.Null(bus.Last);
|
||
}
|
||
}
|
||
```
|
||
|
||
> Verify the `ChatLog` / `ICommandBus` / `ChatVM` APIs used above match the real
|
||
> types before running (`ChatLog.OnSystemMessage(string, int)`, `ChatLog.Snapshot()`,
|
||
> `ChatLog.Clear()`, `ICommandBus.Publish<T>`). Adjust the fixture if signatures differ.
|
||
|
||
- [ ] **Step 2: Run the tests to verify they fail**
|
||
|
||
Run: `dotnet test tests/AcDream.UI.Abstractions.Tests --filter ChatCommandRouterTests`
|
||
Expected: FAIL — `ChatCommandRouter` / `SubmitOutcome` do not exist.
|
||
|
||
- [ ] **Step 3: Implement `ChatCommandRouter`**
|
||
|
||
Create `src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs`. Move the
|
||
client-command + unknown-verb + parse + publish logic out of `ChatPanel`
|
||
(`ChatPanel.TryHandleClientCommand` + the submit block at `ChatPanel.cs:191-242`):
|
||
|
||
```csharp
|
||
using System;
|
||
using AcDream.UI.Abstractions.Panels.Chat;
|
||
|
||
namespace AcDream.UI.Abstractions.Panels.Chat;
|
||
|
||
/// <summary>What a submit did, so the caller can clear its input + give feedback.</summary>
|
||
public enum SubmitOutcome { Empty, ClientHandled, UnknownCommand, Sent, Dropped }
|
||
|
||
/// <summary>
|
||
/// Shared chat-submit pipeline (retail <c>ChatInterface::ProcessCommand @0x4f5100</c>
|
||
/// analogue). Both the ImGui devtools <see cref="ChatPanel"/> and the retail
|
||
/// chat window route through here so command handling stays in one place.
|
||
///
|
||
/// Order mirrors the prior inline <see cref="ChatPanel"/> flow:
|
||
/// client-command intercept → unknown-slash-verb guard → <see cref="ChatInputParser.Parse"/>
|
||
/// → <c>Publish(SendChatCmd)</c>.
|
||
/// </summary>
|
||
public static class ChatCommandRouter
|
||
{
|
||
public static SubmitOutcome Submit(
|
||
string raw, ChatVM vm, ICommandBus bus, ChatChannelKind defaultChannel)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(vm);
|
||
ArgumentNullException.ThrowIfNull(bus);
|
||
var trimmed = (raw ?? string.Empty).Trim();
|
||
if (trimmed.Length == 0) return SubmitOutcome.Empty;
|
||
|
||
if (TryHandleClientCommand(trimmed, vm)) return SubmitOutcome.ClientHandled;
|
||
|
||
// A '/' prefix is a command, never speech — unknown ones get local feedback
|
||
// instead of leaking to the server as chat. (@ verbs pass through to ACE.)
|
||
if (trimmed[0] == '/')
|
||
{
|
||
var verb = ChatInputParser.GetVerbToken(trimmed);
|
||
if (!ChatInputParser.IsKnownVerb(verb))
|
||
{
|
||
vm.ShowSystemMessage(
|
||
$"Unknown command: {verb}. Type /help for the list of supported commands.");
|
||
return SubmitOutcome.UnknownCommand;
|
||
}
|
||
}
|
||
|
||
var parsed = ChatInputParser.Parse(
|
||
trimmed, defaultChannel, vm.LastIncomingTellSender, vm.LastOutgoingTellTarget);
|
||
if (parsed is { } p)
|
||
{
|
||
bus.Publish(new SendChatCmd(p.Channel, p.TargetName, p.Text));
|
||
return SubmitOutcome.Sent;
|
||
}
|
||
return SubmitOutcome.Dropped; // e.g. "/t Name" with no message
|
||
}
|
||
|
||
private static bool TryHandleClientCommand(string trimmed, ChatVM vm)
|
||
{
|
||
if (EqAny(trimmed, "/help", "/?", "/h", "@help", "@?", "@h"))
|
||
{ vm.ShowSystemMessage(BuildHelpText()); return true; }
|
||
if (EqAny(trimmed, "/clear", "/cls", "@clear", "@cls"))
|
||
{ vm.Clear(); return true; }
|
||
if (EqAny(trimmed, "/framerate", "@framerate"))
|
||
{ vm.ShowFps(); return true; }
|
||
if (EqAny(trimmed, "/loc", "@loc"))
|
||
{ vm.ShowLocation(); return true; }
|
||
return false;
|
||
}
|
||
|
||
private static bool EqAny(string s, params string[] options)
|
||
{
|
||
for (int i = 0; i < options.Length; i++)
|
||
if (s.Equals(options[i], StringComparison.OrdinalIgnoreCase)) return true;
|
||
return false;
|
||
}
|
||
|
||
private static string BuildHelpText() =>
|
||
"Note: / and @ are equivalent prefixes.\n" +
|
||
"Chat: /say (default), /tell <name>, /reply, /retell\n" +
|
||
"Channels: /general /trade /fellowship /allegiance\n" +
|
||
" /patron /vassals /monarch /covassals\n" +
|
||
" /lfg /roleplay /society /olthoi\n" +
|
||
"Client: /help (this) /clear /framerate /loc\n" +
|
||
"Server: type @acehelp or @acecommands for ACE's full list.";
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run the tests to verify they pass**
|
||
|
||
Run: `dotnet test tests/AcDream.UI.Abstractions.Tests --filter ChatCommandRouterTests`
|
||
Expected: PASS (5 tests).
|
||
|
||
- [ ] **Step 5: Repoint `ChatPanel` at the router**
|
||
|
||
In `src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs`, replace the submit body
|
||
(`ChatPanel.cs:194-241`, the `var trimmed = submitted.Trim();` block through
|
||
`_input = string.Empty;`) with a single call, and delete the now-dead
|
||
`TryHandleClientCommand` / `EqAny` / `BuildHelpText` helpers (they moved to the router):
|
||
|
||
```csharp
|
||
if (renderer.InputTextSubmit("##chatinput", ref _input, InputBufferMaxLen, out var submitted)
|
||
&& submitted is not null)
|
||
{
|
||
ChatCommandRouter.Submit(submitted, _vm, ctx.Commands, ChatChannelKind.Say);
|
||
_input = string.Empty;
|
||
renderer.EndChild();
|
||
renderer.End();
|
||
return;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Verify the full suite still passes**
|
||
|
||
Run: `dotnet test tests/AcDream.UI.Abstractions.Tests`
|
||
Expected: PASS — including the existing `ChatPanelInputTests` (they assert the same submit behavior, now via the router). If any assert on a private `ChatPanel` member, redirect it to `ChatCommandRouter`.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs \
|
||
src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs \
|
||
tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs
|
||
git commit -m "feat(D.2b): extract ChatCommandRouter — shared chat submit pipeline
|
||
|
||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task B: `UiChatView` dat-font seam + 1-line wheel
|
||
|
||
Make the transcript render in the dat font and scroll one line per wheel notch
|
||
(retail `HandleMouseWheel @0x471450`), keeping bottom-pin, drag-select, Ctrl+C.
|
||
|
||
**Files:**
|
||
- Modify: `src/AcDream.App/UI/UiChatView.cs`
|
||
- Test: `tests/AcDream.App.Tests/UI/UiChatViewDatFontTests.cs`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `tests/AcDream.App.Tests/UI/UiChatViewDatFontTests.cs`. `UiChatView.CharIndexAt`
|
||
is already a pure static taking a `Func<char,float>` advance lookup — assert the
|
||
dat-font advance (`UiDatFont.GlyphAdvance`) drives caret hit-testing:
|
||
|
||
```csharp
|
||
using AcDream.App.UI;
|
||
using DatReaderWriter.Types;
|
||
using Xunit;
|
||
|
||
namespace AcDream.App.Tests.UI;
|
||
|
||
public class UiChatViewDatFontTests
|
||
{
|
||
// Synthetic per-char advance: each glyph 10px wide (Before=2,Width=6,After=2).
|
||
private static FontCharDesc Glyph(char c) => new()
|
||
{
|
||
Unicode = c, HorizontalOffsetBefore = 2, Width = 6, HorizontalOffsetAfter = 2,
|
||
OffsetX = 0, OffsetY = 0, Height = 12, VerticalOffsetBefore = 0,
|
||
};
|
||
|
||
[Fact]
|
||
public void CharIndexAt_UsesDatGlyphAdvance()
|
||
{
|
||
// "abc" with 10px advances -> midpoints at 5,15,25. x=12 -> caret before 'b' (index 1).
|
||
float Adv(char c) => UiDatFont.GlyphAdvance(Glyph(c));
|
||
Assert.Equal(0, UiChatView.CharIndexAt("abc", Adv, 4f));
|
||
Assert.Equal(1, UiChatView.CharIndexAt("abc", Adv, 12f));
|
||
Assert.Equal(3, UiChatView.CharIndexAt("abc", Adv, 100f));
|
||
}
|
||
|
||
[Fact]
|
||
public void GlyphAdvance_MatchesRetailFormula()
|
||
{
|
||
// HorizontalOffsetBefore + Width + HorizontalOffsetAfter = 2+6+2 = 10.
|
||
Assert.Equal(10f, UiDatFont.GlyphAdvance(Glyph('x')));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify it fails or passes-trivially**
|
||
|
||
Run: `dotnet test tests/AcDream.App.Tests --filter UiChatViewDatFontTests`
|
||
Expected: PASS for `GlyphAdvance_MatchesRetailFormula` (it's existing), FAIL only if
|
||
`FontCharDesc` field names differ — fix the `Glyph(...)` initializer to match the
|
||
real `DatReaderWriter.Types.FontCharDesc` (verify via the type before running). The
|
||
first test should already pass since `CharIndexAt` is font-agnostic; this test pins
|
||
the dat-font advance as the lookup.
|
||
|
||
- [ ] **Step 3: Add the dat-font draw + scroll path to `UiChatView`**
|
||
|
||
In `src/AcDream.App/UI/UiChatView.cs`:
|
||
|
||
1. Add a property next to `Font`:
|
||
```csharp
|
||
/// <summary>Retail dat font (0x40000000) for the transcript. When set, glyphs
|
||
/// render via the two-pass dat-font blit and measure/hit-test use the dat glyph
|
||
/// advance; when null, the debug BitmapFont path is used. Set by the controller.</summary>
|
||
public UiDatFont? DatFont { get; set; }
|
||
```
|
||
2. Change the wheel quantum to one line per notch (retail `HandleMouseWheel`):
|
||
```csharp
|
||
private const float WheelLines = 1f; // retail: 1 line per wheel notch (was 3)
|
||
```
|
||
3. In `OnDraw`, branch on `DatFont`: use `DatFont.LineHeight` for `lh`, draw each
|
||
line with `ctx.DrawStringDat(DatFont, text, Padding, y, color)`, and measure the
|
||
selection-highlight span with `DatFont.MeasureWidth(...)`. Keep the `BitmapFont`
|
||
branch unchanged as the fallback. Cache `_lastDatFont` alongside `_lastFont` so
|
||
`HitChar` uses the same advance source it drew with.
|
||
4. In `HitChar`, when `_lastDatFont` is set, build the advance lookup from it:
|
||
```csharp
|
||
int col = _lastDatFont is { } df
|
||
? CharIndexAt(text, ch => df.TryGetGlyph(ch, out var g) ? UiDatFont.GlyphAdvance(g) : 0f,
|
||
localX - _lastPadding)
|
||
: (_lastFont is { } bf
|
||
? CharIndexAt(text, ch => bf.TryGetGlyph(ch, out var bg) ? bg.Advance : 0f,
|
||
localX - _lastPadding)
|
||
: 0);
|
||
```
|
||
5. In the `Scroll` event, use the dat-font line height when present:
|
||
```csharp
|
||
float lh = DatFont?.LineHeight ?? (Font ?? _lastFont)?.LineHeight ?? 16f;
|
||
```
|
||
|
||
- [ ] **Step 4: Run the tests to verify they pass**
|
||
|
||
Run: `dotnet test tests/AcDream.App.Tests --filter UiChatViewDatFontTests`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Build the App project**
|
||
|
||
Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`
|
||
Expected: 0 errors.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/AcDream.App/UI/UiChatView.cs tests/AcDream.App.Tests/UI/UiChatViewDatFontTests.cs
|
||
git commit -m "feat(D.2b): UiChatView dat-font transcript + 1-line wheel quantum
|
||
|
||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task C: `UiScrollable` (pixel-scroll coordinator)
|
||
|
||
Port `UIElement_Scrollable`'s pixel-scroll math: a pure, GL-free coordinator the
|
||
transcript and scrollbar both read. No `UiElement` inheritance — it is held by
|
||
`UiChatView` and queried by `UiChatScrollbar`.
|
||
|
||
**Files:**
|
||
- Create: `src/AcDream.App/UI/UiScrollable.cs`
|
||
- Test: `tests/AcDream.App.Tests/UI/UiScrollableTests.cs`
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
Create `tests/AcDream.App.Tests/UI/UiScrollableTests.cs`:
|
||
|
||
```csharp
|
||
using AcDream.App.UI;
|
||
using Xunit;
|
||
|
||
namespace AcDream.App.Tests.UI;
|
||
|
||
public class UiScrollableTests
|
||
{
|
||
[Fact]
|
||
public void Clamp_KeepsScrollWithinContent()
|
||
{
|
||
var s = new UiScrollable { ContentHeight = 300, ViewHeight = 100 };
|
||
s.SetScrollY(500); // over max
|
||
Assert.Equal(200, s.ScrollY); // max = 300-100
|
||
s.SetScrollY(-50);
|
||
Assert.Equal(0, s.ScrollY);
|
||
}
|
||
|
||
[Fact]
|
||
public void FitsView_PinsToZero()
|
||
{
|
||
var s = new UiScrollable { ContentHeight = 80, ViewHeight = 100 };
|
||
s.SetScrollY(40);
|
||
Assert.Equal(0, s.ScrollY); // content <= view => no scroll
|
||
Assert.False(s.HasOverflow);
|
||
}
|
||
|
||
[Fact]
|
||
public void ThumbRatio_IsViewOverContent_ClampedToOne()
|
||
{
|
||
var s = new UiScrollable { ContentHeight = 400, ViewHeight = 100 };
|
||
Assert.Equal(0.25f, s.ThumbRatio, 3); // 100/400
|
||
var full = new UiScrollable { ContentHeight = 50, ViewHeight = 100 };
|
||
Assert.Equal(1f, full.ThumbRatio, 3); // content < view => full thumb
|
||
}
|
||
|
||
[Fact]
|
||
public void PositionRatio_MapsScrollToZeroOne()
|
||
{
|
||
var s = new UiScrollable { ContentHeight = 300, ViewHeight = 100 };
|
||
s.SetScrollY(100); // half of max(200)
|
||
Assert.Equal(0.5f, s.PositionRatio, 3);
|
||
s.SetScrollY(200);
|
||
Assert.Equal(1f, s.PositionRatio, 3);
|
||
}
|
||
|
||
[Fact]
|
||
public void SetPositionRatio_IsInverseOfPositionRatio()
|
||
{
|
||
var s = new UiScrollable { ContentHeight = 300, ViewHeight = 100 };
|
||
s.SetPositionRatio(0.5f);
|
||
Assert.Equal(100, s.ScrollY); // 0.5 * max(200)
|
||
}
|
||
|
||
[Fact]
|
||
public void ScrollByLines_AdvancesByLineHeight()
|
||
{
|
||
var s = new UiScrollable { ContentHeight = 1000, ViewHeight = 100, LineHeight = 16 };
|
||
s.ScrollByLines(-2); // retail: negative = toward older/top
|
||
Assert.Equal(0, s.ScrollY); // already at top, clamped
|
||
s.SetScrollY(50);
|
||
s.ScrollByLines(2);
|
||
Assert.Equal(82, s.ScrollY); // 50 + 2*16
|
||
}
|
||
|
||
[Fact]
|
||
public void ScrollByPage_AdvancesByViewHeight()
|
||
{
|
||
var s = new UiScrollable { ContentHeight = 1000, ViewHeight = 100, LineHeight = 16 };
|
||
s.SetScrollY(200);
|
||
s.ScrollByPage(1);
|
||
Assert.Equal(300, s.ScrollY); // 200 + view(100)
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify they fail**
|
||
|
||
Run: `dotnet test tests/AcDream.App.Tests --filter UiScrollableTests`
|
||
Expected: FAIL — `UiScrollable` does not exist.
|
||
|
||
- [ ] **Step 3: Implement `UiScrollable`**
|
||
|
||
Create `src/AcDream.App/UI/UiScrollable.cs`. Ports `UIElement_Scrollable`
|
||
(`SetScrollableXY @0x4740c0`, `UpdateScrollbarSize_ @0x4741a0`,
|
||
`UpdateScrollbarPosition_ @0x473f20`, `InqScrollDelta @0x4689b0`):
|
||
|
||
```csharp
|
||
using System;
|
||
|
||
namespace AcDream.App.UI;
|
||
|
||
/// <summary>
|
||
/// Pixel-based vertical scroll model. Port of retail <c>UIElement_Scrollable</c>:
|
||
/// the scroll offset is an integer pixel value (<c>m_iScrollableY</c>) clamped to
|
||
/// [0, ContentHeight - ViewHeight]; the thumb ratio is view/content; the position
|
||
/// ratio is scroll/(content-view). Pure (no GL) so it is fully unit-tested and
|
||
/// shared by the transcript (UiChatView) and the scrollbar (UiChatScrollbar).
|
||
/// </summary>
|
||
public sealed class UiScrollable
|
||
{
|
||
/// <summary>Total wrapped content height in px (UIElement_Scrollable m_iScrollableHeight).</summary>
|
||
public int ContentHeight { get; set; }
|
||
/// <summary>Visible viewport height in px.</summary>
|
||
public int ViewHeight { get; set; }
|
||
/// <summary>Pixels per text line (the scroll quantum). UIElement_Text::InqScrollDelta line case.</summary>
|
||
public int LineHeight { get; set; } = 16;
|
||
|
||
private int _scrollY;
|
||
/// <summary>Current scroll offset in px from the top of the content.</summary>
|
||
public int ScrollY => _scrollY;
|
||
|
||
/// <summary>Max scroll = max(0, content - view).</summary>
|
||
public int MaxScroll => Math.Max(0, ContentHeight - ViewHeight);
|
||
|
||
/// <summary>True when content exceeds the view (a scrollbar is warranted).</summary>
|
||
public bool HasOverflow => ContentHeight > ViewHeight;
|
||
|
||
/// <summary>True when the offset is at (or past) the bottom — used for bottom-pin.</summary>
|
||
public bool AtEnd => _scrollY >= MaxScroll;
|
||
|
||
/// <summary>Set the offset, clamped to [0, MaxScroll] (SetScrollableXY clamp).</summary>
|
||
public void SetScrollY(int y) => _scrollY = Math.Clamp(y, 0, MaxScroll);
|
||
|
||
/// <summary>Pin to the bottom (newest content visible).</summary>
|
||
public void ScrollToEnd() => _scrollY = MaxScroll;
|
||
|
||
/// <summary>Thumb size ratio = view/content, clamped to 1 (UpdateScrollbarSize_).</summary>
|
||
public float ThumbRatio => ContentHeight <= 0 ? 1f : Math.Min(1f, (float)ViewHeight / ContentHeight);
|
||
|
||
/// <summary>Position ratio = scroll/(content-view) in [0,1] (UpdateScrollbarPosition_).</summary>
|
||
public float PositionRatio => MaxScroll <= 0 ? 0f : (float)_scrollY / MaxScroll;
|
||
|
||
/// <summary>Inverse of PositionRatio — used when the user drags the thumb.</summary>
|
||
public void SetPositionRatio(float ratio)
|
||
=> SetScrollY((int)MathF.Round(Math.Clamp(ratio, 0f, 1f) * MaxScroll));
|
||
|
||
/// <summary>Scroll by whole lines (sign: +down/newer, -up/older).</summary>
|
||
public void ScrollByLines(int lines) => SetScrollY(_scrollY + lines * LineHeight);
|
||
|
||
/// <summary>Scroll by a page = one view height (InqScrollDelta page case).</summary>
|
||
public void ScrollByPage(int pages) => SetScrollY(_scrollY + pages * ViewHeight);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run the tests to verify they pass**
|
||
|
||
Run: `dotnet test tests/AcDream.App.Tests --filter UiScrollableTests`
|
||
Expected: PASS (7 tests).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/AcDream.App/UI/UiScrollable.cs tests/AcDream.App.Tests/UI/UiScrollableTests.cs
|
||
git commit -m "feat(D.2b): UiScrollable — pixel scroll model (UIElement_Scrollable port)
|
||
|
||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task C2: Wire `UiScrollable` into `UiChatView`
|
||
|
||
Replace `UiChatView`'s ad-hoc `_scroll` float with a `UiScrollable`, so the
|
||
transcript's content/view height + bottom-pin + line-scroll flow through the
|
||
shared model (and the scrollbar in Task D can read the same instance).
|
||
|
||
**Files:**
|
||
- Modify: `src/AcDream.App/UI/UiChatView.cs`
|
||
|
||
- [ ] **Step 1: Hold a `UiScrollable` + expose it**
|
||
|
||
Add to `UiChatView`:
|
||
```csharp
|
||
/// <summary>The scroll model — also read by the linked UiChatScrollbar.</summary>
|
||
public UiScrollable Scroll { get; } = new();
|
||
```
|
||
|
||
- [ ] **Step 2: Drive it from `OnDraw`**
|
||
|
||
In `OnDraw`, after computing `lh`, `contentH`, `innerH`, set the model and read back
|
||
the offset instead of the local `_scroll`:
|
||
```csharp
|
||
Scroll.LineHeight = (int)MathF.Round(lh);
|
||
Scroll.ContentHeight = (int)MathF.Ceiling(contentH);
|
||
Scroll.ViewHeight = (int)MathF.Floor(innerH);
|
||
// Bottom-pin: if the user was at the end before content grew, stay pinned.
|
||
if (_pinBottom) Scroll.ScrollToEnd();
|
||
float baseY = bottom - contentH + Scroll.ScrollY; // ScrollY is px from top; baseY shifts content
|
||
```
|
||
Keep a `private bool _pinBottom = true;` that is set false when the user scrolls up
|
||
(in the `Scroll` event, `_pinBottom = Scroll.AtEnd;` after applying the delta) and
|
||
true again when they return to the end.
|
||
|
||
> The existing `ClampScroll` static + `_scroll` field are superseded by
|
||
> `UiScrollable`. Keep `ClampScroll` if other tests reference it; otherwise remove it
|
||
> and update `UiChatView`'s scroll-offset reads to `Scroll.ScrollY`.
|
||
|
||
- [ ] **Step 3: Route the wheel through the model**
|
||
|
||
In the `Scroll` event handler:
|
||
```csharp
|
||
case UiEventType.Scroll:
|
||
{
|
||
// Silk wheel +Y = scroll up = reveal older. Retail: 1 line per notch.
|
||
Scroll.ScrollByLines((int)(-e.Data0 * WheelLines));
|
||
_pinBottom = Scroll.AtEnd;
|
||
return true;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Build + run the App tests**
|
||
|
||
Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug && dotnet test tests/AcDream.App.Tests --filter UiChatView`
|
||
Expected: build clean; `UiChatViewDatFontTests` still PASS. Adjust any test that
|
||
referenced the removed `_scroll`/`ClampScroll` to use `Scroll`.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/AcDream.App/UI/UiChatView.cs
|
||
git commit -m "feat(D.2b): UiChatView drives the shared UiScrollable model
|
||
|
||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task D: `UiChatScrollbar` (track + thumb + up/down)
|
||
|
||
A `UiElement` that renders the right-side scrollbar and drives a `UiScrollable`.
|
||
Follows the `UiMeter` sprite pattern (`SpriteResolve` + `ctx.DrawSprite`).
|
||
|
||
**Files:**
|
||
- Create: `src/AcDream.App/UI/UiChatScrollbar.cs`
|
||
|
||
> **First, locate the scroll up/down button ids in the dat.** Run
|
||
> `dotnet run --project src/AcDream.Cli -- dump-vitals-layout "<datdir>" 0x21000006`
|
||
> and inspect the children of track `0x10000012` (and the gold caps seen at the
|
||
> top/bottom of the scrollbar in the retail screenshot). Record the up-button and
|
||
> down-button element ids + their sprite ids in a comment. If the track has no
|
||
> button children, the up/down are part of the track sprite and clicks are handled
|
||
> by hit-region (top 16px = up, bottom 16px = down).
|
||
|
||
- [ ] **Step 1: Implement the widget**
|
||
|
||
Create `src/AcDream.App/UI/UiChatScrollbar.cs`:
|
||
|
||
```csharp
|
||
using System;
|
||
using System.Numerics;
|
||
|
||
namespace AcDream.App.UI;
|
||
|
||
/// <summary>
|
||
/// Right-side chat scrollbar: a track sprite, a draggable thumb sized to the
|
||
/// content/view ratio, and up/down step buttons. Drives a linked
|
||
/// <see cref="UiScrollable"/>. Ports retail <c>UIElement_Scrollbar::UpdateLayout
|
||
/// @0x4710d0</c> (thumb size = trackLen * ThumbRatio, min 8px; thumb pos from
|
||
/// PositionRatio) and <c>HandleButtonClick @0x470e90</c> (step ±1 line).
|
||
/// </summary>
|
||
public sealed class UiChatScrollbar : UiElement
|
||
{
|
||
/// <summary>The scroll model this bar reflects + drives (shared with the transcript).</summary>
|
||
public UiScrollable? Model { get; set; }
|
||
/// <summary>RenderSurface id → (GL tex, w, h). 0 id = skip.</summary>
|
||
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
|
||
|
||
public uint TrackSprite { get; set; } // 0x10000012 face
|
||
public uint ThumbSprite { get; set; } // 0x1000048c face
|
||
public uint UpSprite { get; set; }
|
||
public uint DownSprite { get; set; }
|
||
|
||
private const float MinThumb = 8f; // retail attribute 0x89 floor
|
||
private const float ButtonH = 16f; // up/down button square
|
||
private bool _draggingThumb;
|
||
private float _dragOffsetY;
|
||
|
||
public UiChatScrollbar() { CapturesPointerDrag = true; }
|
||
|
||
/// <summary>Thumb rect in local space (between the two end buttons).</summary>
|
||
public static (float y, float h) ThumbRect(UiScrollable m, float trackTop, float trackLen)
|
||
{
|
||
float h = MathF.Max(MinThumb, trackLen * m.ThumbRatio);
|
||
float travel = trackLen - h;
|
||
float y = trackTop + travel * m.PositionRatio;
|
||
return (y, h);
|
||
}
|
||
|
||
protected override void OnDraw(UiRenderContext ctx)
|
||
{
|
||
if (Model is not { } m || SpriteResolve is not { } resolve) return;
|
||
// Track fills the full height; buttons cap top/bottom; thumb floats between.
|
||
DrawSprite(ctx, resolve, TrackSprite, 0, 0, Width, Height);
|
||
DrawSprite(ctx, resolve, UpSprite, 0, 0, Width, ButtonH);
|
||
DrawSprite(ctx, resolve, DownSprite, 0, Height - ButtonH, Width, ButtonH);
|
||
if (m.HasOverflow)
|
||
{
|
||
float trackTop = ButtonH, trackLen = Height - 2 * ButtonH;
|
||
var (ty, th) = ThumbRect(m, trackTop, trackLen);
|
||
DrawSprite(ctx, resolve, ThumbSprite, 0, ty, Width, th);
|
||
}
|
||
}
|
||
|
||
private void DrawSprite(UiRenderContext ctx, Func<uint,(uint,int,int)> resolve,
|
||
uint id, float x, float y, float w, float h)
|
||
{
|
||
if (id == 0) return;
|
||
var (tex, _, _) = resolve(id);
|
||
if (tex == 0) return;
|
||
ctx.DrawSprite(tex, x, y, w, h, 0f, 0f, 1f, 1f, Vector4.One);
|
||
}
|
||
|
||
public override bool OnEvent(in UiEvent e)
|
||
{
|
||
if (Model is not { } m) return false;
|
||
switch (e.Type)
|
||
{
|
||
case UiEventType.MouseDown:
|
||
{
|
||
float ly = e.Data2; // local Y (UiRoot delivers target-local)
|
||
if (ly <= ButtonH) { m.ScrollByLines(-1); return true; } // up button
|
||
if (ly >= Height - ButtonH) { m.ScrollByLines(1); return true; } // down button
|
||
float trackTop = ButtonH, trackLen = Height - 2 * ButtonH;
|
||
var (ty, th) = ThumbRect(m, trackTop, trackLen);
|
||
if (ly >= ty && ly <= ty + th) { _draggingThumb = true; _dragOffsetY = ly - ty; }
|
||
else m.ScrollByPage(ly < ty ? -1 : 1); // click in track half = page
|
||
return true;
|
||
}
|
||
case UiEventType.MouseMove when _draggingThumb:
|
||
{
|
||
float trackTop = ButtonH, trackLen = Height - 2 * ButtonH;
|
||
float h = MathF.Max(MinThumb, trackLen * m.ThumbRatio);
|
||
float travel = MathF.Max(1f, trackLen - h);
|
||
m.SetPositionRatio((e.Data2 - _dragOffsetY - trackTop) / travel);
|
||
return true;
|
||
}
|
||
case UiEventType.MouseUp: _draggingThumb = false; return true;
|
||
}
|
||
return false;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Build the App project**
|
||
|
||
Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`
|
||
Expected: 0 errors.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/AcDream.App/UI/UiChatScrollbar.cs
|
||
git commit -m "feat(D.2b): UiChatScrollbar — track/thumb/buttons driving UiScrollable
|
||
|
||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task E: `UiChatInput` (editable one-line field)
|
||
|
||
Port the `UIElement_Text` edit path: caret, insert/delete, 100-entry history,
|
||
focus sprite, dat-font draw, submit callback. Caret math reuses `UiDatFont`.
|
||
|
||
**Files:**
|
||
- Create: `src/AcDream.App/UI/UiChatInput.cs`
|
||
- Test: `tests/AcDream.App.Tests/UI/UiChatInputTests.cs`
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
Create `tests/AcDream.App.Tests/UI/UiChatInputTests.cs`. The pure, testable seams are
|
||
text editing + history navigation (no GL). The widget exposes them as instance state:
|
||
|
||
```csharp
|
||
using AcDream.App.UI;
|
||
using Xunit;
|
||
|
||
namespace AcDream.App.Tests.UI;
|
||
|
||
public class UiChatInputTests
|
||
{
|
||
[Fact]
|
||
public void InsertChar_AdvancesCaret()
|
||
{
|
||
var input = new UiChatInput();
|
||
input.InsertChar('h'); input.InsertChar('i');
|
||
Assert.Equal("hi", input.Text);
|
||
Assert.Equal(2, input.CaretPos);
|
||
}
|
||
|
||
[Fact]
|
||
public void Backspace_DeletesBeforeCaret()
|
||
{
|
||
var input = new UiChatInput();
|
||
foreach (var c in "abc") input.InsertChar(c);
|
||
input.MoveCaret(-1); // caret between 'b' and 'c'
|
||
input.Backspace(); // deletes 'b'
|
||
Assert.Equal("ac", input.Text);
|
||
Assert.Equal(1, input.CaretPos);
|
||
}
|
||
|
||
[Fact]
|
||
public void Submit_FiresCallback_ClearsText_PushesHistory()
|
||
{
|
||
string? sent = null;
|
||
var input = new UiChatInput { OnSubmit = t => sent = t };
|
||
foreach (var c in "hello") input.InsertChar(c);
|
||
input.Submit();
|
||
Assert.Equal("hello", sent);
|
||
Assert.Equal("", input.Text);
|
||
Assert.Equal(0, input.CaretPos);
|
||
}
|
||
|
||
[Fact]
|
||
public void EmptySubmit_DoesNotFire()
|
||
{
|
||
int n = 0;
|
||
var input = new UiChatInput { OnSubmit = _ => n++ };
|
||
input.Submit();
|
||
Assert.Equal(0, n);
|
||
}
|
||
|
||
[Fact]
|
||
public void History_UpDownBrowsesPreviousSubmissions()
|
||
{
|
||
var input = new UiChatInput { OnSubmit = _ => {} };
|
||
foreach (var c in "first") input.InsertChar(c); input.Submit();
|
||
foreach (var c in "second") input.InsertChar(c); input.Submit();
|
||
input.HistoryPrev(); // most recent
|
||
Assert.Equal("second", input.Text);
|
||
input.HistoryPrev();
|
||
Assert.Equal("first", input.Text);
|
||
input.HistoryNext();
|
||
Assert.Equal("second", input.Text);
|
||
input.HistoryNext(); // back to live (empty)
|
||
Assert.Equal("", input.Text);
|
||
}
|
||
|
||
[Fact]
|
||
public void History_CapsAt100()
|
||
{
|
||
var input = new UiChatInput { OnSubmit = _ => {} };
|
||
for (int i = 0; i < 150; i++) { input.InsertChar('x'); input.Submit(); }
|
||
Assert.True(input.HistoryCount <= 100);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify they fail**
|
||
|
||
Run: `dotnet test tests/AcDream.App.Tests --filter UiChatInputTests`
|
||
Expected: FAIL — `UiChatInput` does not exist.
|
||
|
||
- [ ] **Step 3: Implement `UiChatInput`**
|
||
|
||
Create `src/AcDream.App/UI/UiChatInput.cs`. Ports `UIElement_Text` editable mode
|
||
(`CharacterHandler`, `MoveCursor @0x468d00`, `FindPixelsFromPos @0x472b40`) +
|
||
`ChatInterface` history (`ProcessCommand @0x4f5100`, `SelectCommandFromHistory`,
|
||
sentinel `-1` = live):
|
||
|
||
```csharp
|
||
using System;
|
||
using System.Collections.Generic;
|
||
using System.Numerics;
|
||
|
||
namespace AcDream.App.UI;
|
||
|
||
/// <summary>
|
||
/// Editable one-line chat input. Port of retail <c>UIElement_Text</c> in editable
|
||
/// one-line mode + <c>ChatInterface</c>'s 100-entry command history. Caret is a
|
||
/// glyph index; the caret pixel-X is Σ glyph advances (UiDatFont) to the caret.
|
||
/// Submit (Enter / Send) fires <see cref="OnSubmit"/>, clears, and pushes history.
|
||
/// </summary>
|
||
public sealed class UiChatInput : UiElement
|
||
{
|
||
public UiDatFont? DatFont { get; set; }
|
||
public BitmapFont? Font { get; set; }
|
||
public Vector4 TextColor { get; set; } = new(1f, 1f, 1f, 1f);
|
||
public Vector4 BackgroundColor { get; set; } = new(0f, 0f, 0f, 0.35f);
|
||
public float Padding { get; set; } = 4f;
|
||
public int MaxCharacters { get; set; } = 0xFFFF; // retail m_nMaxCharacters default
|
||
|
||
/// <summary>Called on Enter/Send with the (non-empty) text. The widget clears after.</summary>
|
||
public Action<string>? OnSubmit { get; set; }
|
||
|
||
private string _text = "";
|
||
private int _caret;
|
||
public string Text => _text;
|
||
public int CaretPos => _caret;
|
||
|
||
private readonly List<string> _history = new();
|
||
private int _historyIndex = -1; // -1 = live line (not browsing)
|
||
public int HistoryCount => _history.Count;
|
||
|
||
public UiChatInput()
|
||
{
|
||
AcceptsFocus = true;
|
||
IsEditControl = true;
|
||
CapturesPointerDrag = true;
|
||
}
|
||
|
||
// ── Pure editing seams (unit-tested) ─────────────────────────────────
|
||
public void InsertChar(char c)
|
||
{
|
||
if (c < 0x20 || c == 0x7F) return; // skip controls (retail CharacterHandler)
|
||
if (_text.Length >= MaxCharacters) return;
|
||
_text = _text.Insert(_caret, c.ToString());
|
||
_caret++;
|
||
_historyIndex = -1; // editing returns to the live line
|
||
}
|
||
|
||
public void Backspace()
|
||
{
|
||
if (_caret == 0) return;
|
||
_text = _text.Remove(_caret - 1, 1);
|
||
_caret--;
|
||
}
|
||
|
||
public void DeleteForward()
|
||
{
|
||
if (_caret >= _text.Length) return;
|
||
_text = _text.Remove(_caret, 1);
|
||
}
|
||
|
||
public void MoveCaret(int delta) => _caret = Math.Clamp(_caret + delta, 0, _text.Length);
|
||
public void CaretHome() => _caret = 0;
|
||
public void CaretEnd() => _caret = _text.Length;
|
||
|
||
public void Submit()
|
||
{
|
||
var t = _text;
|
||
if (t.Trim().Length == 0) { Clear(); return; }
|
||
OnSubmit?.Invoke(t);
|
||
PushHistory(t);
|
||
Clear();
|
||
}
|
||
|
||
private void Clear() { _text = ""; _caret = 0; _historyIndex = -1; }
|
||
|
||
private void PushHistory(string t)
|
||
{
|
||
_history.Add(t);
|
||
if (_history.Count > 100) _history.RemoveAt(0); // retail cap 100, drop oldest
|
||
_historyIndex = -1;
|
||
}
|
||
|
||
public void HistoryPrev() // Up arrow — toward older
|
||
{
|
||
if (_history.Count == 0) return;
|
||
_historyIndex = _historyIndex < 0 ? _history.Count - 1 : Math.Max(0, _historyIndex - 1);
|
||
SetTextFromHistory();
|
||
}
|
||
|
||
public void HistoryNext() // Down arrow — toward newer, then live
|
||
{
|
||
if (_historyIndex < 0) return;
|
||
_historyIndex++;
|
||
if (_historyIndex >= _history.Count) { _historyIndex = -1; Clear(); return; }
|
||
SetTextFromHistory();
|
||
}
|
||
|
||
private void SetTextFromHistory()
|
||
{
|
||
_text = _history[_historyIndex];
|
||
_caret = _text.Length;
|
||
}
|
||
|
||
/// <summary>Caret pixel-X from the text start (FindPixelsFromPos): Σ advances to caret.</summary>
|
||
public float CaretPixelX()
|
||
=> DatFont is { } df ? df.MeasureWidth(_text.Substring(0, _caret))
|
||
: Font is { } bf ? bf.MeasureWidth(_text.Substring(0, _caret)) : 0f;
|
||
|
||
// ── Rendering + input ────────────────────────────────────────────────
|
||
protected override void OnDraw(UiRenderContext ctx)
|
||
{
|
||
ctx.DrawRect(0, 0, Width, Height, BackgroundColor);
|
||
float ty = (Height - (DatFont?.LineHeight ?? Font?.LineHeight ?? 14f)) * 0.5f;
|
||
if (DatFont is { } df) ctx.DrawStringDat(df, _text, Padding, ty, TextColor);
|
||
else if (Font is not null || ctx.DefaultFont is not null) ctx.DrawString(_text, Padding, ty, TextColor, Font);
|
||
|
||
// Caret: 1px vertical line at the caret X (blink left to a follow-up; draw solid for now).
|
||
if (HasKeyboardFocus())
|
||
{
|
||
float cx = Padding + CaretPixelX();
|
||
float ch = DatFont?.LineHeight ?? Font?.LineHeight ?? 14f;
|
||
ctx.DrawRect(cx, ty, 1f, ch, TextColor);
|
||
}
|
||
}
|
||
|
||
private bool HasKeyboardFocus()
|
||
=> (Parent is not null) && FindRoot()?.KeyboardFocus == this;
|
||
|
||
private UiRoot? FindRoot()
|
||
{
|
||
UiElement? e = this;
|
||
while (e is not null) { if (e is UiRoot r) return r; e = e.Parent; }
|
||
return null;
|
||
}
|
||
|
||
public override bool OnEvent(in UiEvent e)
|
||
{
|
||
switch (e.Type)
|
||
{
|
||
case UiEventType.Char:
|
||
InsertChar((char)e.Data0);
|
||
return true;
|
||
case UiEventType.KeyDown:
|
||
{
|
||
var key = (Silk.NET.Input.Key)e.Data0;
|
||
switch (key)
|
||
{
|
||
case Silk.NET.Input.Key.Enter:
|
||
case Silk.NET.Input.Key.KeypadEnter: Submit(); return true;
|
||
case Silk.NET.Input.Key.Backspace: Backspace(); return true;
|
||
case Silk.NET.Input.Key.Delete: DeleteForward(); return true;
|
||
case Silk.NET.Input.Key.Left: MoveCaret(-1); return true;
|
||
case Silk.NET.Input.Key.Right: MoveCaret(1); return true;
|
||
case Silk.NET.Input.Key.Home: CaretHome(); return true;
|
||
case Silk.NET.Input.Key.End: CaretEnd(); return true;
|
||
case Silk.NET.Input.Key.Up: HistoryPrev(); return true;
|
||
case Silk.NET.Input.Key.Down: HistoryNext(); return true;
|
||
}
|
||
return false;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
}
|
||
```
|
||
|
||
> **Note on focus access:** the snippet walks to the `UiRoot` to read `KeyboardFocus`.
|
||
> If `UiRoot.KeyboardFocus` is not reachable that way at runtime, add a
|
||
> `bool Focused` flag set from `UiEventType.FocusGained`/`FocusLost` in `OnEvent`
|
||
> instead (the `UiElement` event model delivers both — see `UiRoot.SetKeyboardFocus`).
|
||
|
||
- [ ] **Step 4: Run the tests to verify they pass**
|
||
|
||
Run: `dotnet test tests/AcDream.App.Tests --filter UiChatInputTests`
|
||
Expected: PASS (6 tests).
|
||
|
||
- [ ] **Step 5: Build the App project**
|
||
|
||
Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`
|
||
Expected: 0 errors. (If `e.Data0` for `Char` is the codepoint per `UiRoot.OnChar`,
|
||
the `(char)e.Data0` cast is correct.)
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/AcDream.App/UI/UiChatInput.cs tests/AcDream.App.Tests/UI/UiChatInputTests.cs
|
||
git commit -m "feat(D.2b): UiChatInput — editable field, caret, 100-entry history (UIElement_Text port)
|
||
|
||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task F: `UiChannelMenu` (channel selector)
|
||
|
||
The `Chat ▸` selector: a button showing the active channel; clicking opens a popup
|
||
list of channels; selecting one fires a channel-changed callback. Ports
|
||
`UIElement_Menu` minimally (a button + a popup item list).
|
||
|
||
**Files:**
|
||
- Create: `src/AcDream.App/UI/UiChannelMenu.cs`
|
||
|
||
- [ ] **Step 1: Implement the widget**
|
||
|
||
Create `src/AcDream.App/UI/UiChannelMenu.cs`. The 13 channels map to
|
||
`ChatChannelKind` (retail `InitTalkFocusMenu @0x4cdc50` enum: 1=Say, 4=Fellowship,
|
||
5=Patron, 6=Trade, 7=Allegiance, …). The popup is a vertical list drawn on click;
|
||
selection updates `Selected` + fires `OnChannelChanged`.
|
||
|
||
```csharp
|
||
using System;
|
||
using System.Collections.Generic;
|
||
using System.Numerics;
|
||
using AcDream.UI.Abstractions;
|
||
|
||
namespace AcDream.App.UI;
|
||
|
||
/// <summary>
|
||
/// Chat channel selector (the "Chat ▸" button). Port of retail
|
||
/// <c>UIElement_Menu</c> as used by <c>gmMainChatUI::InitTalkFocusMenu @0x4cdc50</c>:
|
||
/// a button whose label is the active channel; clicking opens a popup of channels;
|
||
/// selecting one calls <c>SetTalkFocus</c> (here: <see cref="OnChannelChanged"/>).
|
||
/// </summary>
|
||
public sealed class UiChannelMenu : UiElement
|
||
{
|
||
public readonly record struct Item(string Label, ChatChannelKind Channel);
|
||
|
||
/// <summary>Retail talk-focus channels (subset acdream's ChatInputParser routes).</summary>
|
||
public static readonly Item[] Channels =
|
||
{
|
||
new("Say", ChatChannelKind.Say),
|
||
new("General", ChatChannelKind.General),
|
||
new("Trade", ChatChannelKind.Trade),
|
||
new("LFG", ChatChannelKind.Lfg),
|
||
new("Fellowship", ChatChannelKind.Fellowship),
|
||
new("Allegiance", ChatChannelKind.Allegiance),
|
||
new("Patron", ChatChannelKind.Patron),
|
||
new("Vassals", ChatChannelKind.Vassals),
|
||
new("Monarch", ChatChannelKind.Monarch),
|
||
new("Roleplay", ChatChannelKind.Roleplay),
|
||
new("Society", ChatChannelKind.Society),
|
||
new("Olthoi", ChatChannelKind.Olthoi),
|
||
};
|
||
|
||
public ChatChannelKind Selected { get; private set; } = ChatChannelKind.Say;
|
||
public Action<ChatChannelKind>? OnChannelChanged { get; set; }
|
||
|
||
public UiDatFont? DatFont { get; set; }
|
||
public BitmapFont? Font { get; set; }
|
||
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
|
||
public uint NormalSprite { get; set; } // 0x06004D65
|
||
public uint PressedSprite { get; set; } // 0x06004D66
|
||
public Vector4 TextColor { get; set; } = new(1f, 0.85f, 0.4f, 1f);
|
||
|
||
private bool _open;
|
||
private const float ItemH = 16f;
|
||
|
||
public UiChannelMenu() { CapturesPointerDrag = true; }
|
||
|
||
private string Label => FindLabel(Selected);
|
||
private static string FindLabel(ChatChannelKind k)
|
||
{
|
||
foreach (var it in Channels) if (it.Channel == k) return it.Label;
|
||
return "Chat";
|
||
}
|
||
|
||
protected override void OnDraw(UiRenderContext ctx)
|
||
{
|
||
// Button face.
|
||
if (SpriteResolve is { } resolve)
|
||
{
|
||
var (tex, _, _) = resolve(_open ? PressedSprite : NormalSprite);
|
||
if (tex != 0) ctx.DrawSprite(tex, 0, 0, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
|
||
}
|
||
DrawLabel(ctx, Label + " >", 2f, (Height - LineH()) * 0.5f);
|
||
|
||
// Popup list above the button (chat is at screen bottom).
|
||
if (_open)
|
||
{
|
||
float h = Channels.Length * ItemH;
|
||
float top = -h;
|
||
ctx.DrawRect(0, top, MathF.Max(Width, 90f), h, new(0f, 0f, 0f, 0.85f));
|
||
for (int i = 0; i < Channels.Length; i++)
|
||
DrawLabel(ctx, Channels[i].Label, 2f, top + i * ItemH);
|
||
}
|
||
}
|
||
|
||
private float LineH() => DatFont?.LineHeight ?? Font?.LineHeight ?? 14f;
|
||
private void DrawLabel(UiRenderContext ctx, string s, float x, float y)
|
||
{
|
||
if (DatFont is { } df) ctx.DrawStringDat(df, s, x, y, TextColor);
|
||
else ctx.DrawString(s, x, y, TextColor, Font);
|
||
}
|
||
|
||
protected override bool OnHitTest(float lx, float ly)
|
||
=> _open ? (lx >= 0 && lx < MathF.Max(Width, 90f) && ly >= -Channels.Length * ItemH && ly < Height)
|
||
: base.OnHitTest(lx, ly);
|
||
|
||
public override bool OnEvent(in UiEvent e)
|
||
{
|
||
if (e.Type == UiEventType.MouseDown)
|
||
{
|
||
float ly = e.Data2;
|
||
if (_open && ly < 0) // clicked an item in the popup
|
||
{
|
||
int idx = (int)((ly + Channels.Length * ItemH) / ItemH);
|
||
if (idx >= 0 && idx < Channels.Length)
|
||
{
|
||
Selected = Channels[idx].Channel;
|
||
OnChannelChanged?.Invoke(Selected);
|
||
}
|
||
_open = false;
|
||
return true;
|
||
}
|
||
_open = !_open; // toggle on button click
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Build the App project**
|
||
|
||
Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`
|
||
Expected: 0 errors. (Verify `ChatChannelKind` has the members used; adjust the
|
||
`Channels` table to the real enum names if any differ.)
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/AcDream.App/UI/UiChannelMenu.cs
|
||
git commit -m "feat(D.2b): UiChannelMenu — channel selector popup (UIElement_Menu port)
|
||
|
||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task G: `ChatWindowController` (import + bind + route)
|
||
|
||
The `ChatInterface`/`gmMainChatUI::PostInit` analogue: import `0x21000006`, bind by
|
||
id, swap the transcript/input placeholders for the behavioral widgets, wire the
|
||
scrollbar/menu/send/max-min, and route inbound (`ChatVM`) + outbound
|
||
(`ChatCommandRouter`).
|
||
|
||
**Files:**
|
||
- Create: `src/AcDream.App/UI/Layout/ChatWindowController.cs`
|
||
|
||
- [ ] **Step 1: Implement the controller**
|
||
|
||
Create `src/AcDream.App/UI/Layout/ChatWindowController.cs`:
|
||
|
||
```csharp
|
||
using System;
|
||
using AcDream.UI.Abstractions;
|
||
using AcDream.UI.Abstractions.Panels.Chat;
|
||
|
||
namespace AcDream.App.UI.Layout;
|
||
|
||
/// <summary>
|
||
/// Binds the imported chat LayoutDesc (0x21000006) to live behavior — the acdream
|
||
/// analogue of retail <c>ChatInterface</c> + <c>gmMainChatUI::PostInit</c>. It
|
||
/// FindElement(id)s each role, swaps the transcript/input placeholders for the
|
||
/// behavioral widgets, wires the scrollbar/menu/send/max-min, and routes chat.
|
||
/// </summary>
|
||
public sealed class ChatWindowController
|
||
{
|
||
public const uint LayoutId = 0x21000006u;
|
||
public const uint TranscriptId = 0x10000011u;
|
||
public const uint InputId = 0x10000016u;
|
||
public const uint TrackId = 0x10000012u;
|
||
public const uint ThumbId = 0x1000048Cu;
|
||
public const uint MenuId = 0x10000014u;
|
||
public const uint SendId = 0x10000019u;
|
||
public const uint MaxMinId = 0x1000046Fu;
|
||
|
||
public UiChatView Transcript { get; private set; } = null!;
|
||
public UiChatInput Input { get; private set; } = null!;
|
||
public UiChatScrollbar Scrollbar { get; private set; } = null!;
|
||
public UiChannelMenu Menu { get; private set; } = null!;
|
||
|
||
/// <summary>Bind an imported chat layout. Returns the controller, or null if the
|
||
/// required role elements are missing.</summary>
|
||
public static ChatWindowController? Bind(
|
||
ImportedLayout layout, ChatVM vm, ICommandBus bus,
|
||
UiDatFont? datFont, BitmapFont? debugFont,
|
||
Func<uint, (uint, int, int)> resolve)
|
||
{
|
||
var transcriptPh = layout.FindElement(TranscriptId);
|
||
var inputPh = layout.FindElement(InputId);
|
||
if (transcriptPh is null || inputPh is null) return null;
|
||
|
||
var c = new ChatWindowController();
|
||
|
||
// Transcript — swap placeholder for UiChatView at the same rect/anchors.
|
||
c.Transcript = new UiChatView
|
||
{
|
||
Left = transcriptPh.Left, Top = transcriptPh.Top,
|
||
Width = transcriptPh.Width, Height = transcriptPh.Height,
|
||
Anchors = transcriptPh.Anchors,
|
||
DatFont = datFont, Font = debugFont,
|
||
LinesProvider = () => BuildLines(vm),
|
||
};
|
||
ReplaceInParent(transcriptPh, c.Transcript);
|
||
|
||
// Input — swap placeholder for UiChatInput.
|
||
c.Input = new UiChatInput
|
||
{
|
||
Left = inputPh.Left, Top = inputPh.Top,
|
||
Width = inputPh.Width, Height = inputPh.Height,
|
||
Anchors = inputPh.Anchors,
|
||
DatFont = datFont, Font = debugFont,
|
||
};
|
||
ReplaceInParent(inputPh, c.Input);
|
||
|
||
// Menu — swap placeholder for UiChannelMenu (label tracks the active channel).
|
||
var menuPh = layout.FindElement(MenuId);
|
||
c.Menu = new UiChannelMenu { DatFont = datFont, Font = debugFont, SpriteResolve = resolve };
|
||
if (menuPh is not null)
|
||
{
|
||
c.Menu.Left = menuPh.Left; c.Menu.Top = menuPh.Top;
|
||
c.Menu.Width = menuPh.Width; c.Menu.Height = menuPh.Height;
|
||
c.Menu.Anchors = menuPh.Anchors;
|
||
ReplaceInParent(menuPh, c.Menu);
|
||
}
|
||
|
||
// Scrollbar — swap the track placeholder for the scrollbar widget driving the
|
||
// transcript's UiScrollable.
|
||
var trackPh = layout.FindElement(TrackId);
|
||
c.Scrollbar = new UiChatScrollbar { Model = c.Transcript.Scroll, SpriteResolve = resolve };
|
||
if (trackPh is not null)
|
||
{
|
||
c.Scrollbar.Left = trackPh.Left; c.Scrollbar.Top = trackPh.Top;
|
||
c.Scrollbar.Width = trackPh.Width; c.Scrollbar.Height = trackPh.Height;
|
||
c.Scrollbar.Anchors = trackPh.Anchors;
|
||
// Sprite ids: read from the imported track/thumb nodes (TrackSprite, ThumbSprite).
|
||
ReplaceInParent(trackPh, c.Scrollbar);
|
||
}
|
||
|
||
// Routing: input submit -> ChatCommandRouter with the menu's active channel.
|
||
c.Input.OnSubmit = text =>
|
||
ChatCommandRouter.Submit(text, vm, bus, c.Menu.Selected);
|
||
c.Menu.OnChannelChanged = _ => { /* active channel read live from Menu.Selected */ };
|
||
|
||
// Send button -> submit (alternate trigger, retail ListenToElementMessage 0x10000019).
|
||
var send = layout.FindElement(SendId);
|
||
if (send is not null) send.ClickThrough = false; // ensure it receives clicks
|
||
// (wire send click -> c.Input.Submit() in the controller's event hook or via a
|
||
// small click handler subclass; if FindElement returns a UiDatElement, attach
|
||
// an OnClick delegate — add one to UiDatElement if absent.)
|
||
|
||
return c;
|
||
}
|
||
|
||
private static void ReplaceInParent(UiElement placeholder, UiElement widget)
|
||
{
|
||
var parent = placeholder.Parent;
|
||
if (parent is null) return;
|
||
parent.RemoveChild(placeholder);
|
||
parent.AddChild(widget);
|
||
}
|
||
|
||
private static System.Collections.Generic.IReadOnlyList<UiChatView.Line> BuildLines(ChatVM vm)
|
||
{
|
||
var detailed = vm.RecentLinesDetailed();
|
||
var result = new UiChatView.Line[detailed.Count];
|
||
for (int i = 0; i < detailed.Count; i++)
|
||
result[i] = new UiChatView.Line(detailed[i].Text, RetailChatColor(detailed[i].Kind));
|
||
return result;
|
||
}
|
||
|
||
// Per-ChatKind palette (moved from GameWindow.RetailChatColor in Task H).
|
||
private static System.Numerics.Vector4 RetailChatColor(AcDream.Core.Chat.ChatKind kind) => kind switch
|
||
{
|
||
AcDream.Core.Chat.ChatKind.LocalSpeech => new(1f, 1f, 1f, 1f),
|
||
AcDream.Core.Chat.ChatKind.RangedSpeech => new(1f, 0.95f, 0.8f, 1f),
|
||
AcDream.Core.Chat.ChatKind.Channel => new(0.6f, 0.8f, 1f, 1f),
|
||
AcDream.Core.Chat.ChatKind.Tell => new(1f, 0.5f, 1f, 1f),
|
||
AcDream.Core.Chat.ChatKind.System => new(1f, 1f, 0.45f, 1f),
|
||
AcDream.Core.Chat.ChatKind.Popup => new(1f, 0.85f, 0.4f, 1f),
|
||
AcDream.Core.Chat.ChatKind.Emote => new(0.8f, 0.8f, 0.7f, 1f),
|
||
AcDream.Core.Chat.ChatKind.SoulEmote => new(0.8f, 0.8f, 0.7f, 1f),
|
||
AcDream.Core.Chat.ChatKind.Combat => new(1f, 0.6f, 0.25f, 1f),
|
||
_ => new(0.9f, 0.9f, 0.9f, 1f),
|
||
};
|
||
}
|
||
```
|
||
|
||
> **Send-button + max/min click wiring:** `LayoutImporter` builds those as
|
||
> `UiDatElement` sprite nodes. If `UiDatElement` has no click hook, add an
|
||
> `Action? OnClick` invoked from `OnEvent(UiEventType.Click)` (small change, generic
|
||
> + reusable). Wire `send.OnClick = () => Input.Submit();` and
|
||
> `maxmin.OnClick = ToggleMaximize;`. The max/min toggle ports
|
||
> `gmMainChatUI::HandleMaximizeButton @0x4cce50` (swap between authored height and
|
||
> full-parent height, storing old Y/height). If that grows large, file it as a
|
||
> follow-up and leave the button inert this pass (note in a divergence row).
|
||
|
||
- [ ] **Step 2: Build the App project**
|
||
|
||
Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`
|
||
Expected: 0 errors. Resolve the sprite-id reads for the scrollbar (`TrackSprite`/
|
||
`ThumbSprite`) by pulling them from the imported track/thumb `ElementInfo.StateMedia`
|
||
(or `UiDatElement`), following the `DatWidgetFactory.SliceIds` pattern.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/AcDream.App/UI/Layout/ChatWindowController.cs
|
||
git commit -m "feat(D.2b): ChatWindowController — bind chat LayoutDesc, route in/outbound
|
||
|
||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Task H: `GameWindow` cutover + register + roadmap
|
||
|
||
Replace the hand-authored chat block with the controller; default placement; remove
|
||
dead code; add divergence rows; mark the work landed.
|
||
|
||
**Files:**
|
||
- Modify: `src/AcDream.App/Rendering/GameWindow.cs`
|
||
- Modify: `docs/architecture/retail-divergence-register.md`
|
||
- Modify: `docs/plans/2026-04-11-roadmap.md`
|
||
|
||
- [ ] **Step 1: Swap the chat block in `GameWindow`**
|
||
|
||
In `src/AcDream.App/Rendering/GameWindow.cs`, in the `if (_options.RetailUi)` block,
|
||
replace the "Retail chat window" section (`GameWindow.cs:1836-1887`, the
|
||
`retailChatVm` + `UiNineSlicePanel` + `UiChatView` + `BuildRetailChatLines` +
|
||
`RetailChatColor` block) with:
|
||
|
||
```csharp
|
||
// Retail chat window — data-driven from LayoutDesc 0x21000006 (gmMainChatUI),
|
||
// the same importer path as vitals. ChatWindowController binds the transcript,
|
||
// input, scrollbar and channel menu and routes through ChatVM + ChatCommandRouter.
|
||
var retailChatVm = new AcDream.UI.Abstractions.Panels.Chat.ChatVM(Chat, displayLimit: 200);
|
||
AcDream.App.UI.Layout.ImportedLayout? chatLayout;
|
||
lock (_datLock)
|
||
chatLayout = AcDream.App.UI.Layout.LayoutImporter.Import(
|
||
_dats!, AcDream.App.UI.Layout.ChatWindowController.LayoutId, ResolveChrome, vitalsDatFont);
|
||
if (chatLayout is not null)
|
||
{
|
||
var chatController = AcDream.App.UI.Layout.ChatWindowController.Bind(
|
||
chatLayout, retailChatVm, _commandBus, vitalsDatFont, _debugFont, ResolveChrome);
|
||
if (chatController is not null)
|
||
{
|
||
var chatRoot = chatLayout.Root;
|
||
chatRoot.Left = 10; chatRoot.Top = 432; // bottom-left default; user adjusts visually
|
||
chatRoot.Anchors = AcDream.App.UI.AnchorEdges.None;
|
||
chatRoot.Draggable = true;
|
||
chatRoot.Resizable = true;
|
||
chatRoot.MinWidth = 200f; chatRoot.MinHeight = 80f;
|
||
_uiHost.Root.AddChild(chatRoot);
|
||
Console.WriteLine("[D.2b] retail chat window from LayoutDesc importer (0x21000006).");
|
||
}
|
||
else Console.WriteLine("[D.2b] chat: required role elements missing in 0x21000006.");
|
||
}
|
||
else Console.WriteLine("[D.2b] chat: LayoutDesc 0x21000006 not found.");
|
||
```
|
||
|
||
> `_commandBus` must be the live `ICommandBus` the chat `SendChatCmd` handler is
|
||
> registered on. Confirm the field name in `GameWindow` (grep `ICommandBus` /
|
||
> `LiveCommandBus` — it is the same bus the ImGui `ChatPanel` publishes to). If the
|
||
> chat window root needs `vitalsDatFont` loaded first, this block already runs after
|
||
> the vitals block where `vitalsDatFont` is created — keep that ordering.
|
||
|
||
- [ ] **Step 2: Build + run the full suite**
|
||
|
||
Run: `dotnet build && dotnet test`
|
||
Expected: build clean; all tests green. Remove any now-unused `using`/helpers left in
|
||
`GameWindow` (the old `BuildRetailChatLines`/`RetailChatColor` local statics).
|
||
|
||
- [ ] **Step 3: Add divergence-register rows**
|
||
|
||
In `docs/architecture/retail-divergence-register.md`, add one row each (cite
|
||
`file:line`): (1) two-class transcript/input split [Adaptation]; (2) no in-element
|
||
word-wrap [Approximation]; (3) one color per line [Approximation]; (4) chat tabs
|
||
render but don't switch/filter [Stopgap]; (5) squelch + name-tags absent [Stopgap];
|
||
(6) single default opacity, default font face/size [Approximation].
|
||
|
||
- [ ] **Step 4: Visual verification (user)**
|
||
|
||
Launch live and confirm against the retail screenshot:
|
||
```powershell
|
||
$env:ACDREAM_DAT_DIR="$env:USERPROFILE\Documents\Asheron's Call"; $env:ACDREAM_LIVE="1"
|
||
$env:ACDREAM_TEST_HOST="127.0.0.1"; $env:ACDREAM_TEST_PORT="9000"
|
||
$env:ACDREAM_TEST_USER="testaccount"; $env:ACDREAM_TEST_PASS="testpassword"
|
||
$env:ACDREAM_RETAIL_UI="1"
|
||
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath chat-redrive.log
|
||
```
|
||
Confirm: transcript scrolls in the dat font; scrollbar thumb sizes + drags; type +
|
||
Enter/Send dispatch; channel menu switches; window moves/resizes; translucent frame.
|
||
|
||
- [ ] **Step 5: Update the roadmap + commit**
|
||
|
||
Mark the chat re-drive landed in `docs/plans/2026-04-11-roadmap.md` (D.2b importer
|
||
Plan 2 — chat). Commit:
|
||
```bash
|
||
git add src/AcDream.App/Rendering/GameWindow.cs \
|
||
docs/architecture/retail-divergence-register.md docs/plans/2026-04-11-roadmap.md
|
||
git commit -m "feat(D.2b): cut GameWindow over to the data-driven chat window
|
||
|
||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Self-Review checklist (done while writing)
|
||
|
||
- **Spec coverage:** §4 components ↔ Tasks A–H (router→A, transcript dat-font→B,
|
||
scrollable→C/C2, scrollbar→D, input→E, menu→F, controller→G, cutover→H). Deferred
|
||
items (§2/§6) → register rows in H Step 3. ✓
|
||
- **Placeholders:** the two forward-discoveries (scroll up/down button ids in D; send/
|
||
max-min click hook in G) are explicit, scoped implementation tasks with a fallback,
|
||
not hand-waves. ✓
|
||
- **Type consistency:** `UiScrollable` API (`ScrollY`, `ThumbRatio`, `PositionRatio`,
|
||
`SetPositionRatio`, `ScrollByLines/Page`) used consistently in C, C2, D. `UiChatView.Scroll`
|
||
exposed in C2, consumed in D/G. `ChatCommandRouter.Submit(raw, vm, bus, channel)` defined
|
||
in A, called in E-wiring/G. `UiChatInput.OnSubmit`/`Submit()` consistent E↔G. ✓
|