diff --git a/src/AcDream.App/UI/UiScrollable.cs b/src/AcDream.App/UI/UiScrollable.cs new file mode 100644 index 00000000..d30e2a0a --- /dev/null +++ b/src/AcDream.App/UI/UiScrollable.cs @@ -0,0 +1,57 @@ +using System; + +namespace AcDream.App.UI; + +/// +/// Pixel-based vertical scroll model. Port of retail UIElement_Scrollable: +/// the scroll offset is an integer pixel value (m_iScrollableY) 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). +/// Decomp anchors: SetScrollableXY @0x4740c0, UpdateScrollbarSize_ @0x4741a0, +/// UpdateScrollbarPosition_ @0x473f20, UIElement_Text::InqScrollDelta @0x4689b0. +/// +public sealed class UiScrollable +{ + /// Total wrapped content height in px (m_iScrollableHeight). + public int ContentHeight { get; set; } + /// Visible viewport height in px. + public int ViewHeight { get; set; } + /// Pixels per text line (scroll quantum). InqScrollDelta line case. + public int LineHeight { get; set; } = 16; + + private int _scrollY; + /// Current scroll offset in px from the top of the content. + public int ScrollY => _scrollY; + + /// Max scroll = max(0, content - view). + public int MaxScroll => Math.Max(0, ContentHeight - ViewHeight); + + /// True when content exceeds the view (a scrollbar is warranted). + public bool HasOverflow => ContentHeight > ViewHeight; + + /// True when the offset is at (or past) the bottom — used for bottom-pin. + public bool AtEnd => _scrollY >= MaxScroll; + + /// Set the offset, clamped to [0, MaxScroll] (SetScrollableXY clamp). + public void SetScrollY(int y) => _scrollY = Math.Clamp(y, 0, MaxScroll); + + /// Pin to the bottom (newest content visible). + public void ScrollToEnd() => _scrollY = MaxScroll; + + /// Thumb size ratio = view/content, clamped to 1 (UpdateScrollbarSize_). + public float ThumbRatio => ContentHeight <= 0 ? 1f : Math.Min(1f, (float)ViewHeight / ContentHeight); + + /// Position ratio = scroll/(content-view) in [0,1] (UpdateScrollbarPosition_). + public float PositionRatio => MaxScroll <= 0 ? 0f : (float)_scrollY / MaxScroll; + + /// Inverse of PositionRatio — used when the user drags the thumb. + public void SetPositionRatio(float ratio) + => SetScrollY((int)MathF.Round(Math.Clamp(ratio, 0f, 1f) * MaxScroll)); + + /// Scroll by whole lines (sign: +down/newer, -up/older). + public void ScrollByLines(int lines) => SetScrollY(_scrollY + lines * LineHeight); + + /// Scroll by a page = one view height (InqScrollDelta page case). + public void ScrollByPage(int pages) => SetScrollY(_scrollY + pages * ViewHeight); +} diff --git a/tests/AcDream.App.Tests/UI/UiScrollableTests.cs b/tests/AcDream.App.Tests/UI/UiScrollableTests.cs new file mode 100644 index 00000000..27804b1c --- /dev/null +++ b/tests/AcDream.App.Tests/UI/UiScrollableTests.cs @@ -0,0 +1,73 @@ +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); + Assert.Equal(200, s.ScrollY); + 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); + 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); + var full = new UiScrollable { ContentHeight = 50, ViewHeight = 100 }; + Assert.Equal(1f, full.ThumbRatio, 3); + } + + [Fact] + public void PositionRatio_MapsScrollToZeroOne() + { + var s = new UiScrollable { ContentHeight = 300, ViewHeight = 100 }; + s.SetScrollY(100); + 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); + } + + [Fact] + public void ScrollByLines_AdvancesByLineHeight() + { + var s = new UiScrollable { ContentHeight = 1000, ViewHeight = 100, LineHeight = 16 }; + s.ScrollByLines(-2); + Assert.Equal(0, s.ScrollY); + s.SetScrollY(50); + s.ScrollByLines(2); + Assert.Equal(82, s.ScrollY); + } + + [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); + } +}