Files
TheriapolisV3/Theriapolis.Game/CodexUI/Widgets/CodexLabel.cs
T

148 lines
5.3 KiB
C#
Raw 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- or multi-line text widget. Wraps to <see cref="MaxWidth"/> if set;
/// otherwise to its arrange width. Color and font are explicit so the same
/// widget can render the codex header (DisplayLarge / Ink), an eyebrow
/// (MonoTag / InkMute), or a body paragraph (SerifBody / InkSoft).
/// </summary>
public sealed class CodexLabel : CodexWidget
{
public string Text { get; set; } = "";
public SpriteFontBase Font { get; set; }
public Color Color { get; set; } = CodexColors.Ink;
public HAlign HAlign { get; set; } = HAlign.Left;
/// <summary>If set, text wraps when its measured width would exceed this.</summary>
public int? MaxWidth { get; set; }
public CodexLabel(string text, SpriteFontBase font, Color? color = null, HAlign hAlign = HAlign.Left)
{
Text = text;
Font = font;
if (color.HasValue) Color = color.Value;
HAlign = hAlign;
}
private string[] _wrappedLines = System.Array.Empty<string>();
protected override Point MeasureCore(Point available)
{
int wrapW = MaxWidth ?? available.X;
_wrappedLines = WrapText(Text, Font, wrapW);
int width = 0;
foreach (var line in _wrappedLines)
{
var s = Font.MeasureString(line);
if (s.X > width) width = (int)System.MathF.Ceiling(s.X);
}
int height = (int)System.MathF.Ceiling(Font.LineHeight * (_wrappedLines.Length == 0 ? 1 : _wrappedLines.Length));
return new Point(System.Math.Min(width, wrapW), height);
}
protected override void ArrangeCore(Rectangle bounds) { }
public override void Draw(SpriteBatch sb, GameTime gt)
{
float y = Bounds.Y;
foreach (var line in _wrappedLines)
{
var s = Font.MeasureString(line);
float x = HAlign switch
{
HAlign.Center => Bounds.X + (Bounds.Width - s.X) / 2f,
HAlign.Right => Bounds.X + Bounds.Width - s.X,
_ => Bounds.X,
};
Font.DrawText(sb, line, new Vector2(x, y), Color);
y += Font.LineHeight;
}
}
/// <summary>
/// Greedy word wrap. Splits on spaces, fits as many words as possible per
/// line, hard-breaks oversize words. Honours embedded \n.
/// </summary>
public static string[] WrapText(string text, SpriteFontBase font, int maxWidth)
{
if (string.IsNullOrEmpty(text)) return new[] { "" };
var lines = new System.Collections.Generic.List<string>();
foreach (var paragraph in text.Split('\n'))
{
var words = paragraph.Split(' ');
var current = new System.Text.StringBuilder();
foreach (var word in words)
{
string trial = current.Length == 0 ? word : current + " " + word;
if (font.MeasureString(trial).X > maxWidth && current.Length > 0)
{
lines.Add(current.ToString());
current.Clear();
current.Append(word);
}
else
{
current.Clear();
current.Append(trial);
}
}
lines.Add(current.ToString());
}
return lines.ToArray();
}
}
/// <summary>
/// Decorative horizontal rule with a small central diamond glyph. Mirrors
/// the React design's <c>.divider</c> / <c>.clade-group-label::after</c>
/// hairline + ornament pattern.
/// </summary>
public sealed class CodexOrnamentRule : CodexWidget
{
public Color RuleColor { get; set; } = CodexColors.Rule;
public string? Label { get; set; }
public SpriteFontBase Font { get; set; }
private readonly CodexAtlas _atlas;
public CodexOrnamentRule(CodexAtlas atlas, SpriteFontBase font, string? label = null)
{
_atlas = atlas;
Font = font;
Label = label;
}
protected override Point MeasureCore(Point available) => new(available.X, 16);
protected override void ArrangeCore(Rectangle bounds) { }
public override void Draw(SpriteBatch sb, GameTime gt)
{
int midY = Bounds.Y + Bounds.Height / 2;
if (!string.IsNullOrEmpty(Label))
{
var s = Font.MeasureString(Label);
int padX = 12;
int textX = Bounds.X + 0;
// Left rule before the text.
sb.Draw(_atlas.Pixel, new Rectangle(Bounds.X, midY, 16, 1), RuleColor);
int textXStart = Bounds.X + 16 + padX / 2;
Font.DrawText(sb, Label, new Vector2(textXStart, midY - Font.LineHeight / 2f), CodexColors.InkMute);
int afterText = textXStart + (int)s.X + padX / 2;
sb.Draw(_atlas.Pixel, new Rectangle(afterText, midY, Bounds.Right - afterText, 1), RuleColor);
}
else
{
int half = (Bounds.Width - 16) / 2;
sb.Draw(_atlas.Pixel, new Rectangle(Bounds.X, midY, half, 1), RuleColor);
sb.Draw(_atlas.OrnamentDiamond, new Rectangle(Bounds.X + half, midY - 8, 16, 16), CodexColors.Gild);
sb.Draw(_atlas.Pixel, new Rectangle(Bounds.X + half + 16, midY, half, 1), RuleColor);
}
}
}