Files

112 lines
4.4 KiB
C#
Raw Permalink Normal View History

using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Theriapolis.Game.CodexUI.Core;
namespace Theriapolis.Game.CodexUI.Widgets;
/// <summary>
/// Vertical scroll container. Measures its child at unbounded height to
/// learn the full content size, then arranges the child shifted by
/// <see cref="ScrollOffset"/>. 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.
/// </summary>
public sealed class ScrollPanel : CodexWidget
{
public CodexWidget? Child { get; set; }
public int ScrollOffset { get; private set; }
private int _contentHeight;
private readonly CodexAtlas _atlas;
/// <summary>
/// Fires whenever the wheel changes the scroll offset. The wizard
/// uses this to persist offset across <c>InvalidateLayout</c>: the
/// rebuilt tree creates a new <see cref="ScrollPanel"/>, but the
/// stored value gets re-applied via <see cref="SetInitialScroll"/>.
/// </summary>
public System.Action<int>? OnScrollChanged { get; set; }
public ScrollPanel(CodexAtlas atlas, CodexWidget? child = null)
{
_atlas = atlas;
Child = child;
if (child is not null) child.Parent = this;
}
/// <summary>Restore a saved offset before the first measure-arrange pass runs.</summary>
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;
}
}