using FontStashSharp; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; using Theriapolis.Game.CodexUI.Core; namespace Theriapolis.Game.CodexUI.Widgets; /// /// Single floating popover panel. The screen owns one instance; widgets /// (chips, bonus pills) request it to show by calling /// 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 .trait-hint: parchment fill, gilded /// border, italic display title + tag pill, body paragraph in serif body /// face. The "Plainly Reading" footnote is supported via . /// 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; } /// /// 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. /// 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() : 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; }