Files

167 lines
6.7 KiB
C#
Raw Permalink Normal View History

using FontStashSharp;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Theriapolis.Game.CodexUI.Core;
namespace Theriapolis.Game.CodexUI.Widgets;
/// <summary>
/// Single floating popover panel. The screen owns one instance; widgets
/// (chips, bonus pills) request it to show by calling <see cref="Show"/>
/// with their trigger bounds + content. Visibility decays automatically
/// when the trigger no longer reports as hovered. Position is clamped to
/// the viewport so popovers near the right/bottom edges flip to fit.
///
/// Mirrors the React design's <c>.trait-hint</c>: parchment fill, gilded
/// border, italic display title + tag pill, body paragraph in serif body
/// face. The "Plainly Reading" footnote is supported via <see cref="Reading"/>.
/// </summary>
public sealed class CodexHoverPopover : CodexWidget
{
private readonly CodexAtlas _atlas;
private string _title = "";
private string _body = "";
private string? _tag;
private string? _reading;
private bool _detriment;
private Rectangle _triggerBounds;
private bool _showRequestedThisFrame;
public bool IsShown { get; private set; }
public string? Reading { get => _reading; set => _reading = value; }
public CodexHoverPopover(CodexAtlas atlas)
{
_atlas = atlas;
}
/// <summary>
/// Request the popover. Called from a widget's Update when it detects
/// hover. The popover stays visible only as long as some widget requests
/// it each frame.
/// </summary>
public void Show(Rectangle triggerBounds, string title, string body, string? tag = null, bool detriment = false)
{
_triggerBounds = triggerBounds;
_title = title;
_body = body;
_tag = tag;
_detriment = detriment;
_showRequestedThisFrame = true;
}
protected override Point MeasureCore(Point available) => Point.Zero;
protected override void ArrangeCore(Rectangle bounds) { }
public override void Update(GameTime gt, CodexInput input)
{
IsShown = _showRequestedThisFrame;
_showRequestedThisFrame = false;
}
public override void Draw(SpriteBatch sb, GameTime gt)
{
if (!IsShown) return;
const int width = 320;
var titleFont = CodexFonts.SerifItalic;
var bodyFont = CodexFonts.SerifBody;
var tagFont = CodexFonts.MonoTagSmall;
// Wrap body text to the width.
var titleLines = CodexLabel.WrapText(_title, titleFont, width - 32);
var bodyLines = CodexLabel.WrapText(_body, bodyFont, width - 32);
var readingLines = string.IsNullOrEmpty(_reading) ? System.Array.Empty<string>() : CodexLabel.WrapText(_reading!, bodyFont, width - 32);
int height = 14
+ (int)(titleFont.LineHeight * titleLines.Length)
+ 6
+ (int)(bodyFont.LineHeight * bodyLines.Length)
+ (readingLines.Length > 0 ? 8 + (int)(bodyFont.LineHeight * readingLines.Length) + 4 : 0)
+ 12;
// Position — prefer below the trigger, flip above if it doesn't
// fit there, and as a last resort clamp to whichever edge gives
// more room. Earlier code clamped only with `if (y < 8) y = 8`,
// which would push the popover off the bottom whenever the
// trigger sat near the viewport's bottom edge and the popover
// didn't fit above either.
int x = _triggerBounds.X;
if (x + width > _viewport.Width) x = _viewport.Width - width - 8;
if (x < 8) x = 8;
int spaceBelow = _viewport.Height - _triggerBounds.Bottom - 6;
int spaceAbove = _triggerBounds.Y - 6;
int y;
if (height <= spaceBelow)
{
y = _triggerBounds.Bottom + 6;
}
else if (height <= spaceAbove)
{
y = _triggerBounds.Y - height - 6;
}
else
{
// Doesn't fit either side; clamp so the popover sits within
// the viewport with at least an 8-px margin on the limiting side.
y = System.Math.Max(8, _viewport.Height - height - 8);
}
var rect = new Rectangle(x, y, width, height);
// Background
sb.Draw(_atlas.Pixel, rect, CodexColors.Bg2);
Color border = _detriment ? CodexColors.Seal : CodexColors.Gild;
sb.Draw(_atlas.Pixel, new Rectangle(rect.X, rect.Y, rect.Width, 1), border);
sb.Draw(_atlas.Pixel, new Rectangle(rect.X, rect.Bottom - 1, rect.Width, 1), border);
sb.Draw(_atlas.Pixel, new Rectangle(rect.X, rect.Y, 1, rect.Height), border);
sb.Draw(_atlas.Pixel, new Rectangle(rect.Right - 1, rect.Y, 1, rect.Height), border);
int cy = rect.Y + 12;
// Title (+ optional tag)
foreach (var line in titleLines)
{
titleFont.DrawText(sb, line, new Vector2(rect.X + 16, cy), CodexColors.Ink);
cy += (int)titleFont.LineHeight;
}
if (!string.IsNullOrEmpty(_tag))
{
var tagSize = tagFont.MeasureString(_tag);
int tagX = rect.X + 16;
int tagY = cy;
sb.Draw(_atlas.Pixel, new Rectangle(tagX, tagY, (int)tagSize.X + 12, (int)tagFont.LineHeight + 4), Color.Transparent);
sb.Draw(_atlas.Pixel, new Rectangle(tagX, tagY, (int)tagSize.X + 12, 1), CodexColors.Seal);
sb.Draw(_atlas.Pixel, new Rectangle(tagX, tagY + (int)tagFont.LineHeight + 3, (int)tagSize.X + 12, 1), CodexColors.Seal);
sb.Draw(_atlas.Pixel, new Rectangle(tagX, tagY, 1, (int)tagFont.LineHeight + 4), CodexColors.Seal);
sb.Draw(_atlas.Pixel, new Rectangle(tagX + (int)tagSize.X + 11, tagY, 1, (int)tagFont.LineHeight + 4), CodexColors.Seal);
tagFont.DrawText(sb, _tag, new Vector2(tagX + 6, tagY + 2), CodexColors.Seal);
cy += (int)tagFont.LineHeight + 6;
}
else cy += 6;
// Body
foreach (var line in bodyLines)
{
bodyFont.DrawText(sb, line, new Vector2(rect.X + 16, cy), CodexColors.InkSoft);
cy += (int)bodyFont.LineHeight;
}
if (readingLines.Length > 0)
{
cy += 4;
sb.Draw(_atlas.Pixel, new Rectangle(rect.X + 16, cy, rect.Width - 32, 1), new Color(CodexColors.Rule.R, CodexColors.Rule.G, CodexColors.Rule.B, (byte)128));
cy += 6;
foreach (var line in readingLines)
{
bodyFont.DrawText(sb, line, new Vector2(rect.X + 16, cy), CodexColors.InkMute);
cy += (int)bodyFont.LineHeight;
}
}
Reading = null; // consumed
}
private Rectangle _viewport = new(0, 0, 1280, 800);
public void UpdateViewport(Rectangle vp) => _viewport = vp;
}