using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; using Theriapolis.Game.CodexUI.Core; namespace Theriapolis.Game.CodexUI.Widgets; /// /// Vertical scroll container. Measures its child at unbounded height to /// learn the full content size, then arranges the child shifted by /// . Mouse-wheel input changes the offset; bounds /// hit-testing for hover/click still uses screen-space, so widgets that /// scroll out of view simply stop receiving cursor events. /// /// Drawing is uncapped — child widgets draw in their offset positions, so /// content above/below the visible band can spill into adjacent regions. /// The screen's stepper and nav bar are painted with opaque backgrounds /// to mask this overflow, which is cheaper than scissor clipping and avoids /// the SpriteBatch end/restart dance. /// public sealed class ScrollPanel : CodexWidget { public CodexWidget? Child { get; set; } public int ScrollOffset { get; private set; } private int _contentHeight; private readonly CodexAtlas _atlas; /// /// Fires whenever the wheel changes the scroll offset. The wizard /// uses this to persist offset across InvalidateLayout: the /// rebuilt tree creates a new , but the /// stored value gets re-applied via . /// public System.Action? OnScrollChanged { get; set; } public ScrollPanel(CodexAtlas atlas, CodexWidget? child = null) { _atlas = atlas; Child = child; if (child is not null) child.Parent = this; } /// Restore a saved offset before the first measure-arrange pass runs. public void SetInitialScroll(int offset) => ScrollOffset = offset; protected override Point MeasureCore(Point available) { if (Child is null) { _contentHeight = 0; return new Point(available.X, available.Y); } var s = Child.Measure(new Point(System.Math.Max(0, available.X - 8), int.MaxValue / 2)); _contentHeight = s.Y; return new Point(available.X, available.Y); } protected override void ArrangeCore(Rectangle bounds) { ClampScroll(); Child?.Arrange(new Rectangle(bounds.X, bounds.Y - ScrollOffset, System.Math.Max(0, bounds.Width - 8), _contentHeight)); } public override void Update(GameTime gt, CodexInput input) { if (Bounds.Contains(input.MousePosition) && input.ScrollDelta != 0) { ScrollOffset -= input.ScrollDelta / 2; ClampScroll(); Child?.Arrange(new Rectangle(Bounds.X, Bounds.Y - ScrollOffset, System.Math.Max(0, Bounds.Width - 8), _contentHeight)); OnScrollChanged?.Invoke(ScrollOffset); } // Nest a clip into the visible viewport so children scrolled out // of view don't register hover/click. Intersect with any outer // clip the parent already set so we never widen its scope. var prevClip = input.GetMouseClip(); var newClip = prevClip is Rectangle p ? Rectangle.Intersect(p, Bounds) : Bounds; input.SetMouseClip(newClip); Child?.Update(gt, input); if (prevClip is Rectangle r) input.SetMouseClip(r); else input.ClearMouseClip(); } public override void Draw(SpriteBatch sb, GameTime gt) { Child?.Draw(sb, gt); // Scrollbar thumb on the right edge — only when content overflows. if (_contentHeight > Bounds.Height) { int trackX = Bounds.Right - 4; int trackH = Bounds.Height; sb.Draw(_atlas.Pixel, new Rectangle(trackX, Bounds.Y, 2, trackH), new Color(CodexColors.Rule.R, CodexColors.Rule.G, CodexColors.Rule.B, (byte)80)); int thumbH = System.Math.Max(24, (int)((float)trackH * trackH / _contentHeight)); float t = (float)ScrollOffset / System.Math.Max(1, _contentHeight - trackH); int thumbY = Bounds.Y + (int)((trackH - thumbH) * t); sb.Draw(_atlas.Pixel, new Rectangle(trackX, thumbY, 2, thumbH), CodexColors.Gild); } } private void ClampScroll() { int max = System.Math.Max(0, _contentHeight - Bounds.Height); if (ScrollOffset < 0) ScrollOffset = 0; if (ScrollOffset > max) ScrollOffset = max; } }