using System; using System.Collections.Generic; using System.Linq; using System.Numerics; namespace AcDream.App.UI; /// /// Simple vertical viewport for controller-built row lists. It shares the same /// pixel scroll model as chat text and item grids. A row that straddles the /// viewport edge stays visible and is CLIPPED to the viewport (#371 — /// routes the draw walk and hit-testing through /// ); before that fix this class hid a /// straddling row WHOLE, which made the Chat tab's 260px per-window filter /// blocks vanish into a void at the default scroll offset (AP-201's predicted /// symptom, user-observed at Campaign OP gate 3). /// public sealed class UiScrollablePanel : UiPanel { /// protected override bool ClipsChildren => true; private readonly Dictionary _baseTops = new(ReferenceEqualityComparer.Instance); public UiScrollable Scroll { get; } = new(); public int LineHeight { get; set; } = 16; public int ContentHeight { get; private set; } public UiScrollablePanel() { BackgroundColor = Vector4.Zero; BorderColor = Vector4.Zero; } public override void AddChild(UiElement child) { base.AddChild(child); Track(child); } public override bool RemoveChild(UiElement child) { bool removed = base.RemoveChild(child); if (removed) { _baseTops.Remove(child); RecomputeContentHeight(); } return removed; } public void ClearContent() { foreach (var child in ChildrenBackToFrontSnapshot()) RemoveChild(child); _baseTops.Clear(); ContentHeight = 0; Scroll.SetScrollY(0); } internal void LayoutScrollableChildren() { Scroll.LineHeight = Math.Max(1, LineHeight); Scroll.ContentHeight = ContentHeight; Scroll.ViewHeight = Math.Max(0, (int)MathF.Floor(Height)); Scroll.SetScrollY(Scroll.ScrollY); foreach (var child in Children) { if (!_baseTops.TryGetValue(child, out float baseTop)) continue; float top = baseTop - Scroll.ScrollY; child.Top = top; // #371: INTERSECTION, not full containment — a straddling row stays // visible and ClipsChildren trims it to the viewport at draw and // hit-test time. Rows entirely outside stay hidden (cheap skip); // the half-pixel margin excludes zero-overlap rows sitting exactly // on either edge. child.Visible = top + child.Height > 0.5f && top < Height - 0.5f; } } public override bool OnEvent(in UiEvent e) { if (e.Type == UiEventType.Scroll) { Scroll.ScrollByLines(-e.Data0); LayoutScrollableChildren(); return true; } return base.OnEvent(e); } protected override void OnDraw(UiRenderContext ctx) { LayoutScrollableChildren(); base.OnDraw(ctx); } private void Track(UiElement child) { child.Anchors = AnchorEdges.None; _baseTops[child] = child.Top; RecomputeContentHeight(); } private void RecomputeContentHeight() { float bottom = 0f; foreach (var child in Children) { if (!_baseTops.TryGetValue(child, out float top)) continue; bottom = MathF.Max(bottom, top + child.Height); } ContentHeight = (int)MathF.Ceiling(bottom); Scroll.SetScrollY(Scroll.ScrollY); } }