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:
Erik 2026-06-16 17:02:49 +02:00
parent d1b13a7dbf
commit 3593d6623d
8 changed files with 49 additions and 50 deletions

View file

@ -1,208 +0,0 @@
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>
/// <remarks>
/// Dat element ids (chat LayoutDesc 0x21000006): track 0x10000012 (X=474 Y=6 W=16 H=68),
/// thumb 0x1000048C. The track is instanced from base layout 0x2100003E which contains
/// the full scrollbar widget with distinct up/down button children:
/// Up button element 0x10000071 — Y=0, 16×16, Normal sprite 0x06004C69.
/// Down button element 0x10000072 — Y=32, 16×16, Normal sprite 0x06004C6C.
/// Track body sprite: 0x06004C5F (48px tall in the base template; stretched to H=68 in chat).
/// Thumb is a 3-slice: top cap 0x06004C60, middle 0x06004C63, bottom cap 0x06004C66.
/// For Task H wiring: up/down regions occupy the top and bottom ButtonH (16px) of the
/// 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
{
/// <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; }
/// <summary>Track background sprite id (0x06004C5F from layout 0x2100003E element 0x10000455).</summary>
public uint TrackSprite { get; set; }
/// <summary>Thumb 3-slice MIDDLE tile sprite id (0x06004C63), tiled between the caps.</summary>
public uint ThumbSprite { get; set; }
/// <summary>Thumb 3-slice TOP cap sprite id (0x06004C60, 3px tall).</summary>
public uint ThumbTopSprite { get; set; }
/// <summary>Thumb 3-slice BOTTOM cap sprite id (0x06004C66, 3px tall).</summary>
public uint ThumbBotSprite { get; set; }
/// <summary>Up-arrow button sprite id (0x06004C69 Normal state, element 0x10000071).</summary>
public uint UpSprite { get; set; }
/// <summary>Down-arrow button sprite id (0x06004C6C Normal state, element 0x10000072).</summary>
public uint DownSprite { get; set; }
/// <summary>Retail attribute 0x89 floor: minimum thumb height in pixels.</summary>
private const float MinThumb = 8f;
/// <summary>Thumb cap height (native sprite height from base layout 0x2100003E).</summary>
private const float CapH = 3f;
/// <summary>Up/down button height in pixels. Matches element height 16px from
/// the up/down button children in base layout 0x2100003E.</summary>
private const float ButtonH = 16f;
private bool _draggingThumb;
private float _dragOffsetY;
public UiChatScrollbar() { CapturesPointerDrag = true; }
/// <summary>
/// Computes the thumb rectangle (local y origin and height) within the track area
/// between the two end buttons. Ports retail <c>UIElement_Scrollbar::UpdateLayout
/// @0x4710d0</c>: thumb height = max(MinThumb, trackLen * ThumbRatio); thumb top
/// offset = trackTop + (trackLen - thumbH) * PositionRatio.
/// </summary>
/// <param name="m">The scroll model.</param>
/// <param name="trackTop">Y of the top of the usable track area (below up-button).</param>
/// <param name="trackLen">Pixel length of the usable track area (between up and down buttons).</param>
/// <returns>Local Y of the thumb's top edge, and its pixel height.</returns>
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 background — TILED vertically (retail DrawMode=Normal). The native track
// sprite (~16×32) repeats to fill the element height instead of stretch-distorting.
DrawTiled(ctx, resolve, TrackSprite, 0f, 0f, Width, Height);
// Up button — top ButtonH rows. UpSprite (0x06004C6C) is the up-arrow art, drawn 1:1.
DrawSprite(ctx, resolve, UpSprite, 0f, 0f, Width, ButtonH);
// Down button — bottom ButtonH rows. DownSprite (0x06004C69) is the down-arrow art.
DrawSprite(ctx, resolve, DownSprite, 0f, Height - ButtonH, Width, ButtonH);
// Thumb — only when content overflows the view. Retail 3-slice: top cap +
// tiled middle + bottom cap (base layout 0x2100003E thumb sub-elements
// 0x10000364/65/66). Falls back to a single tiled middle if the caps are unset
// or the thumb is too short to hold both caps.
if (m.HasOverflow)
{
float trackTop = ButtonH;
float trackLen = Height - 2f * ButtonH;
var (ty, th) = ThumbRect(m, trackTop, trackLen);
if (ThumbTopSprite != 0 && ThumbBotSprite != 0 && th >= 2f * CapH)
{
DrawSprite(ctx, resolve, ThumbTopSprite, 0f, ty, Width, CapH);
DrawTiled(ctx, resolve, ThumbSprite, 0f, ty + CapH, Width, th - 2f * CapH);
DrawSprite(ctx, resolve, ThumbBotSprite, 0f, ty + th - CapH, Width, CapH);
}
else
{
DrawTiled(ctx, resolve, ThumbSprite, 0f, ty, Width, th);
}
}
}
/// <summary>Draw a sprite stretched 1:1 to the dest rect.</summary>
private void DrawSprite(UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve,
uint id, float x, float y, float w, float h)
{
if (id == 0 || w <= 0f || h <= 0f) return;
var (tex, _, _) = resolve(id);
if (tex == 0) return;
ctx.DrawSprite(tex, x, y, w, h, 0f, 0f, 1f, 1f, Vector4.One);
}
/// <summary>Draw a sprite 1:1 but vertically FLIPPED (V0/V1 swapped) — used to point
/// the top scroll button's (down-art) arrow upward.</summary>
private void DrawSpriteFlipV(UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve,
uint id, float x, float y, float w, float h)
{
if (id == 0 || w <= 0f || h <= 0f) return;
var (tex, _, _) = resolve(id);
if (tex == 0) return;
ctx.DrawSprite(tex, x, y, w, h, 0f, 1f, 1f, 0f, Vector4.One);
}
/// <summary>Draw a sprite TILED to fill the dest rect (UV-repeat at native size on
/// both axes — the UI texture is GL_REPEAT-wrapped). A native-width axis gives 1:1.</summary>
private void DrawTiled(UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve,
uint id, float x, float y, float w, float h)
{
if (id == 0 || w <= 0f || h <= 0f) return;
var (tex, tw, th) = resolve(id);
if (tex == 0 || tw == 0 || th == 0) return;
ctx.DrawSprite(tex, x, y, w, h, 0f, 0f, w / tw, h / th, Vector4.One);
}
public override bool OnEvent(in UiEvent e)
{
if (Model is not { } m) return false;
switch (e.Type)
{
case UiEventType.MouseDown:
{
// e.Data1 = local X, e.Data2 = local Y (int pixel coords, see UiRoot hit dispatch).
float ly = e.Data2;
// Up-button region: top ButtonH rows.
if (ly <= ButtonH) { m.ScrollByLines(-1); return true; }
// Down-button region: bottom ButtonH rows.
if (ly >= Height - ButtonH) { m.ScrollByLines(1); return true; }
// Track interior: start a thumb drag or page-scroll.
float trackTop = ButtonH;
float trackLen = Height - 2f * ButtonH;
var (ty, th) = ThumbRect(m, trackTop, trackLen);
if (ly >= ty && ly <= ty + th)
{
// Clicked inside the thumb — begin drag with offset from thumb top.
_draggingThumb = true;
_dragOffsetY = ly - ty;
}
else
{
// Clicked above or below thumb — page scroll (HandleButtonClick page case).
m.ScrollByPage(ly < ty ? -1 : 1);
}
return true;
}
case UiEventType.MouseMove when _draggingThumb:
{
// Map current local Y (minus drag offset from thumb top) back to a
// position ratio across the available travel distance.
float trackTop = ButtonH;
float trackLen = Height - 2f * ButtonH;
float thumbH = MathF.Max(MinThumb, trackLen * m.ThumbRatio);
float travel = MathF.Max(1f, trackLen - thumbH);
float newRatio = ((float)e.Data2 - _dragOffsetY - trackTop) / travel;
m.SetPositionRatio(newRatio);
return true;
}
case UiEventType.MouseUp:
_draggingThumb = false;
return true;
}
return false;
}
}