From 2940b4e3b2a36f912f94df4fb3ebf8f4407b0428 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 15 Jun 2026 22:31:12 +0200 Subject: [PATCH] =?UTF-8?q?feat(D.2b):=20UiChatScrollbar=20=E2=80=94=20tra?= =?UTF-8?q?ck/thumb/buttons=20driving=20UiScrollable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the right-side chat scrollbar widget. Ports retail UIElement_Scrollbar::UpdateLayout @0x4710d0 (thumb sizing + placement) and HandleButtonClick @0x470e90 (step ±1 line, page on track click). Dat element ids sourced from chat LayoutDesc 0x21000006 (base layout 0x2100003E): up-button sprite 0x06004C69, down-button 0x06004C6C, track 0x06004C5F, thumb middle 0x06004C63. Up/down buttons occupy the top and bottom ButtonH (16px) regions of the widget height, matching element positions Y=0 and Y=32 in the base scrollbar template. Adds 6 pure ThumbRect tests (no GL): sizing, clamping to MinThumb, position at start/mid/end, no-overflow full-fill. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/UI/UiChatScrollbar.cs | 164 ++++++++++++++++++ .../UI/UiChatScrollbarTests.cs | 81 +++++++++ 2 files changed, 245 insertions(+) create mode 100644 src/AcDream.App/UI/UiChatScrollbar.cs create mode 100644 tests/AcDream.App.Tests/UI/UiChatScrollbarTests.cs diff --git a/src/AcDream.App/UI/UiChatScrollbar.cs b/src/AcDream.App/UI/UiChatScrollbar.cs new file mode 100644 index 00000000..6274f7b4 --- /dev/null +++ b/src/AcDream.App/UI/UiChatScrollbar.cs @@ -0,0 +1,164 @@ +using System; +using System.Numerics; + +namespace AcDream.App.UI; + +/// +/// Right-side chat scrollbar: a track sprite, a draggable thumb sized to the +/// content/view ratio, and up/down step buttons. Drives a linked +/// . Ports retail UIElement_Scrollbar::UpdateLayout +/// @0x4710d0 (thumb size = trackLen * ThumbRatio, min 8px; thumb pos from +/// PositionRatio) and HandleButtonClick @0x470e90 (step ±1 line). +/// +/// +/// 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. +/// +public sealed class UiChatScrollbar : UiElement +{ + /// The scroll model this bar reflects + drives (shared with the transcript). + public UiScrollable? Model { get; set; } + + /// RenderSurface id → (GL tex, w, h). 0 id = skip. + public Func? SpriteResolve { get; set; } + + /// Track background sprite id (0x06004C5F from layout 0x2100003E element 0x10000455). + public uint TrackSprite { get; set; } + + /// Thumb sprite id (3-slice middle tile: 0x06004C63; the widget draws + /// a single stretched sprite for simplicity — Task H can upgrade to 3-slice). + public uint ThumbSprite { get; set; } + + /// Up-arrow button sprite id (0x06004C69 Normal state, element 0x10000071). + public uint UpSprite { get; set; } + + /// Down-arrow button sprite id (0x06004C6C Normal state, element 0x10000072). + public uint DownSprite { get; set; } + + /// Retail attribute 0x89 floor: minimum thumb height in pixels. + private const float MinThumb = 8f; + + /// Up/down button height in pixels. Matches element height 16px from + /// the up/down button children in base layout 0x2100003E. + private const float ButtonH = 16f; + + private bool _draggingThumb; + private float _dragOffsetY; + + public UiChatScrollbar() { CapturesPointerDrag = true; } + + /// + /// Computes the thumb rectangle (local y origin and height) within the track area + /// between the two end buttons. Ports retail UIElement_Scrollbar::UpdateLayout + /// @0x4710d0: thumb height = max(MinThumb, trackLen * ThumbRatio); thumb top + /// offset = trackTop + (trackLen - thumbH) * PositionRatio. + /// + /// The scroll model. + /// Y of the top of the usable track area (below up-button). + /// Pixel length of the usable track area (between up and down buttons). + /// Local Y of the thumb's top edge, and its pixel height. + 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, full element bounds. + DrawSprite(ctx, resolve, TrackSprite, 0f, 0f, Width, Height); + + // Up button — top ButtonH rows. + DrawSprite(ctx, resolve, UpSprite, 0f, 0f, Width, ButtonH); + + // Down button — bottom ButtonH rows. + DrawSprite(ctx, resolve, DownSprite, 0f, Height - ButtonH, Width, ButtonH); + + // Thumb — only when content overflows the view. + if (m.HasOverflow) + { + float trackTop = ButtonH; + float trackLen = Height - 2f * ButtonH; + var (ty, th) = ThumbRect(m, trackTop, trackLen); + DrawSprite(ctx, resolve, ThumbSprite, 0f, ty, Width, th); + } + } + + private void DrawSprite(UiRenderContext ctx, Func 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: + { + // 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; + } +} diff --git a/tests/AcDream.App.Tests/UI/UiChatScrollbarTests.cs b/tests/AcDream.App.Tests/UI/UiChatScrollbarTests.cs new file mode 100644 index 00000000..3f4ddbba --- /dev/null +++ b/tests/AcDream.App.Tests/UI/UiChatScrollbarTests.cs @@ -0,0 +1,81 @@ +using AcDream.App.UI; +using Xunit; + +namespace AcDream.App.Tests.UI; + +/// +/// Pure unit tests for — no GL dependency. +/// +public class UiChatScrollbarTests +{ + // Model: content=400, view=100, trackLen=200. + // ThumbRatio = 100/400 = 0.25 → thumbH = max(8, 200*0.25) = 50. + // Travel = 200 - 50 = 150. + + [Fact] + public void ThumbRect_AtStart_HasCorrectSizeAndZeroOffset() + { + var m = new UiScrollable { ContentHeight = 400, ViewHeight = 100 }; + // PositionRatio = 0 (start). + var (y, h) = UiChatScrollbar.ThumbRect(m, trackTop: 0f, trackLen: 200f); + Assert.Equal(50f, h, 3f); + Assert.Equal(0f, y, 3f); + } + + [Fact] + public void ThumbRect_AtEnd_PinsToBottomOfTrack() + { + 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); + Assert.Equal(50f, h, 3f); + // y = trackTop + travel * 1 = 16 + 150 = 166. + Assert.Equal(166f, y, 3f); + } + + [Fact] + public void ThumbRect_WithButtonH_CorrectlyOffsetsFromTrackTop() + { + // Matches task spec: content=400, view=100, trackLen=200, PositionRatio=1. + // 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); + Assert.Equal(50f, h, 3f); + Assert.Equal(166f, y, 3f); // 16 + 150 + } + + [Fact] + public void ThumbRect_MidScroll_InterpolatesPosition() + { + // content=400 view=100 → MaxScroll=300; ScrollY=150 → PositionRatio=0.5. + var m = new UiScrollable { ContentHeight = 400, ViewHeight = 100 }; + m.SetScrollY(150); + Assert.Equal(0.5f, m.PositionRatio, 3); + + var (y, h) = UiChatScrollbar.ThumbRect(m, trackTop: 0f, trackLen: 200f); + Assert.Equal(50f, h, 3f); + // y = 0 + 150 * 0.5 = 75. + Assert.Equal(75f, y, 3f); + } + + [Fact] + public void ThumbRect_SmallContent_EnforcesMinThumb() + { + // 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); + Assert.Equal(8f, h, 3f); + } + + [Fact] + public void ThumbRect_NoOverflow_ThumbFillsTrack() + { + // content <= view → ThumbRatio = 1 → thumbH = trackLen. + var m = new UiScrollable { ContentHeight = 50, ViewHeight = 100 }; + var (y, h) = UiChatScrollbar.ThumbRect(m, trackTop: 16f, trackLen: 100f); + Assert.Equal(100f, h, 3f); + Assert.Equal(16f, y, 3f); // travel = 0 → y = trackTop + } +}