feat(D.2b): UiScrollbar (Type 11) — promote the generic chat scrollbar (widget-generalization Task 2)
- git mv UiChatScrollbar.cs → UiScrollbar.cs; rename class + update doc summary to "Generic scrollbar. Ports retail UIElement_Scrollbar (RegisterElementClass(0xb) @ acclient_2013_pseudo_c.txt:124137); thumb size = trackLen * ThumbRatio (min 8px); step ±1 line." - git mv UiChatScrollbarTests.cs → UiScrollbarTests.cs; rename test class + replace every UiChatScrollbar reference with UiScrollbar (bodies unchanged). - DatWidgetFactory: register Type 11 → new UiScrollbar() before the _ fallback case. - ChatWindowController: change Scrollbar property type to UiScrollbar; replace the old "construct-remove-add" block with a "find factory-built UiScrollbar and bind in place" block (no RemoveChild/AddChild); keep `var track` assignment in scope so the Max/Min block's track.Left/track.Width reads still compile against UiElement?. - AP-41 divergence register: update file:line to UiScrollbar.cs:35; narrow wording to "fallback only — single-tile drawn only when cap ids are unset; the chat controller passes all three cap ids so the 3-slice path is the active code path." - Update inline UiChatScrollbar doc-comment references in UiScrollable.cs + UiChatView.cs. - Full suite: 399 passed, 2 skipped (dat/tower fixture skips), 0 failed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d1b13a7dbf
commit
3593d6623d
8 changed files with 49 additions and 50 deletions
|
|
@ -138,7 +138,7 @@ accepted-divergence entries (#96, #49, #50).
|
|||
| AP-38 | Chat transcript renders pre-split `ChatLog` lines 1:1; no in-element word-wrap at the panel's current pixel width | `src/AcDream.App/UI/UiChatView.cs` | Retail does in-element wrap via `UIElement_Text::SizeToFit`; our pre-split lines are always shorter than 440 px in practice; a line that overflows clips at the edge rather than wrapping | Very long server system messages (server shutdowns, broadcast announcements) clip rather than wrapping — no information loss, just visual truncation | `UIElement_Text::SizeToFit` @0x467980; `gmMainChatUI` layout |
|
||||
| AP-39 | Chat lines carry one color per `ChatKind` (per-line solid color); retail `UIElement_Text` supports per-glyph styled runs (bold, different hue per segment) | `src/AcDream.App/UI/UiChatView.cs:13` | Retail glyph-run parsing lives inside keystone.dll with no PDB/decomp; per-line per-kind coloring is the correct tonal palette and covers all existing chat types | Chat lines retail renders with multiple colors or bold names (e.g. "PlayerName says: text") render as one flat color; subtle visual difference but functionally complete | `UIElement_Text` glyph-run styling (keystone.dll, no decomp) |
|
||||
| AP-40 | Single default translucency for the chat window chrome; no focused/unfocused opacity transition; dat font face/size taken from the vitals `vitalsDatFont` (same dat font, not a chat-specific size lookup) | `src/AcDream.App/Rendering/GameWindow.cs` (chatController binding line) | Retail fades the chat window to ~80% alpha when unfocused (`gmMainChatUI::UpdateAlpha @0x4cdea0`); the opacity animation deferred to the Plan-2 window-manager input integration; sharing `vitalsDatFont` is safe — retail uses the same AC-default font for both | The chat window is always fully opaque/same-font rather than subtly fading when idle; no wrong text, but the focused/unfocused breathing rhythm is absent | `gmMainChatUI::UpdateAlpha` @0x4cdea0; `UCF::SetAceFont @0x4d3940` |
|
||||
| AP-41 | Scrollbar thumb drawn as a single stretched sprite (`0x06004C63`, the 3-slice middle tile) instead of retail's 3-slice: top cap `0x06004C60` + tiled middle `0x06004C63` + bottom cap `0x06004C66` | `src/AcDream.App/UI/UiChatScrollbar.cs:37` | The middle tile stretches acceptably at chat-panel dimensions; the 3-slice port is a Task-H upgrade acknowledged inline in the `ThumbSprite` property comment | The thumb's top and bottom edges lack the retail end-cap sprites — slightly wrong visual shape at small thumb sizes (thumb too-short for the middle tile to cleanly scale) | `UIElement_Scrollbar::UpdateLayout @0x4710d0`; cap sprites `0x06004C60` (top) + `0x06004C66` (bottom) from base layout `0x2100003E` |
|
||||
| AP-41 | Scrollbar thumb 3-slice cap fallback only: single-tile draw (`0x06004C63`) used only when `ThumbTopSprite`/`ThumbBotSprite` are unset; the chat controller passes all three cap ids so the 3-slice path is drawn in practice | `src/AcDream.App/UI/UiScrollbar.cs:35` | The fallback single-tile path is unreachable when caps are bound (chat controller always sets them); the 3-slice path is the active code path | Only if a future caller omits the cap ids will the fallback fire — no visual regression in the chat window | `UIElement_Scrollbar::UpdateLayout @0x4710d0`; cap sprites `0x06004C60` (top) + `0x06004C66` (bottom) from base layout `0x2100003E` |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -20,9 +20,9 @@ namespace AcDream.App.UI.Layout;
|
|||
/// <see cref="ElementInfo"/> tree (which contains everything) and adds the behavioral
|
||||
/// widgets as children of their parent container widgets (transcript panel
|
||||
/// <c>0x10000010</c> / input bar <c>0x10000013</c>) which ARE created as
|
||||
/// <see cref="UiDatElement"/> nodes. The scrollbar track (<c>0x10000012</c>) and
|
||||
/// channel menu (<c>0x10000014</c>) are created by the factory and are replaced
|
||||
/// with their behavioral counterparts here.
|
||||
/// <see cref="UiDatElement"/> nodes. The scrollbar track (<c>0x10000012</c>) is built
|
||||
/// directly as a <see cref="UiScrollbar"/> by the factory (Type 11) and is bound in place
|
||||
/// here. The channel menu (<c>0x10000014</c>) is still replaced with its behavioral counterpart.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class ChatWindowController
|
||||
|
|
@ -71,7 +71,7 @@ public sealed class ChatWindowController
|
|||
public UiChatInput Input { get; private set; } = null!;
|
||||
|
||||
/// <summary>Scrollbar widget, driven by <see cref="Transcript"/>'s scroll model.</summary>
|
||||
public UiChatScrollbar Scrollbar { get; private set; } = null!;
|
||||
public UiScrollbar Scrollbar { get; private set; } = null!;
|
||||
|
||||
/// <summary>Channel-selector menu widget.</summary>
|
||||
public UiChannelMenu Menu { get; private set; } = null!;
|
||||
|
|
@ -110,7 +110,7 @@ public sealed class ChatWindowController
|
|||
/// <param name="debugFont">Fallback debug bitmap font (used when
|
||||
/// <paramref name="datFont"/> is null).</param>
|
||||
/// <param name="resolve">Dat RenderSurface id → (GL tex handle, px width, px height).
|
||||
/// Forwarded to <see cref="UiChatScrollbar"/> and <see cref="UiChannelMenu"/>.</param>
|
||||
/// Forwarded to <see cref="UiScrollbar"/> and <see cref="UiChannelMenu"/>.</param>
|
||||
public static ChatWindowController? Bind(
|
||||
ElementInfo rootInfo,
|
||||
ImportedLayout layout,
|
||||
|
|
@ -196,33 +196,24 @@ public sealed class ChatWindowController
|
|||
inputBar.AddChild(c.Input);
|
||||
c.Input.OnSubmit = text => ChatCommandRouter.Submit(text, vm, busProvider(), c._activeChannel);
|
||||
|
||||
// ── Scrollbar — replace the imported track placeholder ────────────
|
||||
// The factory created a UiDatElement for the track. Remove it and place a
|
||||
// behavioral UiChatScrollbar at the same position, driving the transcript's scroll.
|
||||
// ── Scrollbar — bind the factory-built Type-11 track element ────────
|
||||
// The factory now builds the Type-11 track element (0x10000012) as a UiScrollbar
|
||||
// directly. Find it, bind it in place — no remove/add needed.
|
||||
var track = layout.FindElement(TrackId);
|
||||
if (track?.Parent is { } trackParent)
|
||||
if (track is UiScrollbar bar)
|
||||
{
|
||||
c.Scrollbar = new UiChatScrollbar
|
||||
{
|
||||
// Pull the bar up to the panel top so the top arrow meets the window
|
||||
// border (and lines up with the max/min button at root y=0); the dat
|
||||
// track sits 6px down, which left a gap after the resize-bar reclaim.
|
||||
Left = track.Left,
|
||||
Top = 0f,
|
||||
Width = track.Width,
|
||||
Height = track.Height + track.Top,
|
||||
Anchors = track.Anchors,
|
||||
Model = c.Transcript.Scroll,
|
||||
SpriteResolve = resolve,
|
||||
TrackSprite = TrackSprite,
|
||||
ThumbSprite = ThumbSprite,
|
||||
ThumbTopSprite = ThumbTopSprite,
|
||||
ThumbBotSprite = ThumbBotSprite,
|
||||
UpSprite = UpSprite,
|
||||
DownSprite = DownSprite,
|
||||
};
|
||||
trackParent.RemoveChild(track);
|
||||
trackParent.AddChild(c.Scrollbar);
|
||||
float oldTop = bar.Top;
|
||||
bar.Top = 0f; // pull up to the panel top (resize-bar reclaim)
|
||||
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;
|
||||
}
|
||||
|
||||
// ── Channel menu — replace the imported menu placeholder ──────────
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ public static class DatWidgetFactory
|
|||
UiElement e = info.Type switch
|
||||
{
|
||||
7 => BuildMeter(info, resolve, datFont), // UIElement_Meter
|
||||
11 => new UiScrollbar(), // UIElement_Scrollbar (reg :124137)
|
||||
_ => new UiDatElement(info, resolve), // generic fallback for all other types
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ public sealed class UiChatView : UiElement
|
|||
/// <summary>Inner text inset from the view edges, px.</summary>
|
||||
public float Padding { get; set; } = 4f;
|
||||
|
||||
/// <summary>The scroll model — also read by the linked UiChatScrollbar.</summary>
|
||||
/// <summary>The scroll model — also read by the linked UiScrollbar.</summary>
|
||||
public UiScrollable Scroll { get; } = new();
|
||||
|
||||
/// <summary>True while the view is pinned to the newest line (auto-scrolls as content grows).</summary>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ namespace AcDream.App.UI;
|
|||
/// 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).
|
||||
/// shared by the transcript (UiChatView) and the scrollbar (UiScrollbar).
|
||||
/// Decomp anchors: SetScrollableXY @0x4740c0, UpdateScrollbarSize_ @0x4741a0,
|
||||
/// UpdateScrollbarPosition_ @0x473f20, UIElement_Text::InqScrollDelta @0x4689b0.
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -4,11 +4,9 @@ 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).
|
||||
/// Generic scrollbar. Ports retail <c>UIElement_Scrollbar</c>
|
||||
/// (RegisterElementClass(0xb) @ acclient_2013_pseudo_c.txt:124137);
|
||||
/// thumb size = trackLen * ThumbRatio (min 8px); step ±1 line.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Dat element ids (chat LayoutDesc 0x21000006): track 0x10000012 (X=474 Y=6 W=16 H=68),
|
||||
|
|
@ -22,7 +20,7 @@ namespace AcDream.App.UI;
|
|||
/// rendered scrollbar's height; the widget responds to those regions directly via hit
|
||||
/// comparison in OnEvent without requiring separate child elements.
|
||||
/// </remarks>
|
||||
public sealed class UiChatScrollbar : UiElement
|
||||
public sealed class UiScrollbar : UiElement
|
||||
{
|
||||
/// <summary>The scroll model this bar reflects + drives (shared with the transcript).</summary>
|
||||
public UiScrollable? Model { get; set; }
|
||||
|
|
@ -61,7 +59,7 @@ public sealed class UiChatScrollbar : UiElement
|
|||
private bool _draggingThumb;
|
||||
private float _dragOffsetY;
|
||||
|
||||
public UiChatScrollbar() { CapturesPointerDrag = true; }
|
||||
public UiScrollbar() { CapturesPointerDrag = true; }
|
||||
|
||||
/// <summary>
|
||||
/// Computes the thumb rectangle (local y origin and height) within the track area
|
||||
|
|
@ -97,6 +97,15 @@ public class DatWidgetFactoryTests
|
|||
Assert.Null(DatWidgetFactory.Create(noMedia, NoTex, null));
|
||||
}
|
||||
|
||||
// ── Test 5b: Type 11 → UiScrollbar ──────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Type11_Scrollbar_MakesUiScrollbar()
|
||||
{
|
||||
var e = DatWidgetFactory.Create(new ElementInfo { Type = 11, Width = 16, Height = 68 }, NoTex, null);
|
||||
Assert.IsType<UiScrollbar>(e);
|
||||
}
|
||||
|
||||
// ── Test 6: Meter slice extraction (the important one) ───────────────────
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@ using Xunit;
|
|||
namespace AcDream.App.Tests.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Pure unit tests for <see cref="UiChatScrollbar.ThumbRect"/> — no GL dependency.
|
||||
/// Pure unit tests for <see cref="UiScrollbar.ThumbRect"/> — no GL dependency.
|
||||
/// </summary>
|
||||
public class UiChatScrollbarTests
|
||||
public class UiScrollbarTests
|
||||
{
|
||||
// Model: content=400, view=100, trackLen=200.
|
||||
// ThumbRatio = 100/400 = 0.25 → thumbH = max(8, 200*0.25) = 50.
|
||||
|
|
@ -17,7 +17,7 @@ public class UiChatScrollbarTests
|
|||
{
|
||||
var m = new UiScrollable { ContentHeight = 400, ViewHeight = 100 };
|
||||
// PositionRatio = 0 (start).
|
||||
var (y, h) = UiChatScrollbar.ThumbRect(m, trackTop: 0f, trackLen: 200f);
|
||||
var (y, h) = UiScrollbar.ThumbRect(m, trackTop: 0f, trackLen: 200f);
|
||||
Assert.Equal(50f, h, 3f);
|
||||
Assert.Equal(0f, y, 3f);
|
||||
}
|
||||
|
|
@ -28,7 +28,7 @@ public class UiChatScrollbarTests
|
|||
var m = new UiScrollable { ContentHeight = 400, ViewHeight = 100 };
|
||||
m.ScrollToEnd(); // PositionRatio = 1.
|
||||
float trackTop = 16f, trackLen = 200f;
|
||||
var (y, h) = UiChatScrollbar.ThumbRect(m, trackTop, trackLen);
|
||||
var (y, h) = UiScrollbar.ThumbRect(m, trackTop, trackLen);
|
||||
Assert.Equal(50f, h, 3f);
|
||||
// y = trackTop + travel * 1 = 16 + 150 = 166.
|
||||
Assert.Equal(166f, y, 3f);
|
||||
|
|
@ -41,7 +41,7 @@ public class UiChatScrollbarTests
|
|||
// thumbH=50; travel=150; y = trackTop + 150 = trackTop + 150.
|
||||
var m = new UiScrollable { ContentHeight = 400, ViewHeight = 100 };
|
||||
m.ScrollToEnd();
|
||||
var (y, h) = UiChatScrollbar.ThumbRect(m, trackTop: 16f, trackLen: 200f);
|
||||
var (y, h) = UiScrollbar.ThumbRect(m, trackTop: 16f, trackLen: 200f);
|
||||
Assert.Equal(50f, h, 3f);
|
||||
Assert.Equal(166f, y, 3f); // 16 + 150
|
||||
}
|
||||
|
|
@ -54,7 +54,7 @@ public class UiChatScrollbarTests
|
|||
m.SetScrollY(150);
|
||||
Assert.Equal(0.5f, m.PositionRatio, 3);
|
||||
|
||||
var (y, h) = UiChatScrollbar.ThumbRect(m, trackTop: 0f, trackLen: 200f);
|
||||
var (y, h) = UiScrollbar.ThumbRect(m, trackTop: 0f, trackLen: 200f);
|
||||
Assert.Equal(50f, h, 3f);
|
||||
// y = 0 + 150 * 0.5 = 75.
|
||||
Assert.Equal(75f, y, 3f);
|
||||
|
|
@ -65,7 +65,7 @@ public class UiChatScrollbarTests
|
|||
{
|
||||
// content=1000, view=10, trackLen=200 → ThumbRatio=0.01 → raw=2 < 8 → clamp to 8.
|
||||
var m = new UiScrollable { ContentHeight = 1000, ViewHeight = 10 };
|
||||
var (_, h) = UiChatScrollbar.ThumbRect(m, trackTop: 0f, trackLen: 200f);
|
||||
var (_, h) = UiScrollbar.ThumbRect(m, trackTop: 0f, trackLen: 200f);
|
||||
Assert.Equal(8f, h, 3f);
|
||||
}
|
||||
|
||||
|
|
@ -74,7 +74,7 @@ public class UiChatScrollbarTests
|
|||
{
|
||||
// content <= view → ThumbRatio = 1 → thumbH = trackLen.
|
||||
var m = new UiScrollable { ContentHeight = 50, ViewHeight = 100 };
|
||||
var (y, h) = UiChatScrollbar.ThumbRect(m, trackTop: 16f, trackLen: 100f);
|
||||
var (y, h) = UiScrollbar.ThumbRect(m, trackTop: 16f, trackLen: 100f);
|
||||
Assert.Equal(100f, h, 3f);
|
||||
Assert.Equal(16f, y, 3f); // travel = 0 → y = trackTop
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue