feat(D.2b): UiScrollable — pixel scroll model (UIElement_Scrollable port)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-15 22:19:29 +02:00
parent 7552dcba39
commit 9f273c9343
2 changed files with 130 additions and 0 deletions

View file

@ -0,0 +1,57 @@
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).
/// Decomp anchors: SetScrollableXY @0x4740c0, UpdateScrollbarSize_ @0x4741a0,
/// UpdateScrollbarPosition_ @0x473f20, UIElement_Text::InqScrollDelta @0x4689b0.
/// </summary>
public sealed class UiScrollable
{
/// <summary>Total wrapped content height in px (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 (scroll quantum). 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);
}

View file

@ -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);
}
}