using System; using System.Collections.Generic; using System.Numerics; using System.Text; using Robust.Client.Graphics; using Robust.Client.UserInterface.Controls; using Robust.Client.UserInterface.RichText; using Robust.Shared.Collections; using Robust.Shared.Maths; using Robust.Shared.Utility; namespace Robust.Client.UserInterface { /// /// Used by and to handle rich text layout. /// Note that if this text is ever removed or modified without removing the owning control, /// then should be called to ensure that any controls that were added by this /// entry are also removed. /// internal struct RichTextEntry { public static readonly Type[] DefaultTags = [ typeof(BoldItalicTag), typeof(BoldTag), typeof(BulletTag), typeof(ColorTag), typeof(HeadingTag), typeof(ItalicTag) ]; private readonly Color _defaultColor; private readonly Type[]? _tagsAllowed; public readonly FormattedMessage Message; /// /// The vertical size of this entry, in pixels. /// public int Height; /// /// The horizontal size of this entry, in pixels. /// public int Width; /// /// The combined text indices in the message's text tags to put line breaks. /// public ValueList LineBreaks; public readonly Dictionary? Controls; public RichTextEntry( FormattedMessage message, Control parent, MarkupTagManager tagManager, Color? defaultColor = null) : this(message, parent, tagManager, DefaultTags, defaultColor) { // RichTextEntry constructor but with DefaultTags } public RichTextEntry(FormattedMessage message, Control parent, MarkupTagManager tagManager, Type[]? tagsAllowed, Color? defaultColor = null) { Message = message; Height = 0; Width = 0; LineBreaks = default; _defaultColor = defaultColor ?? new(200, 200, 200); _tagsAllowed = tagsAllowed; Controls = GetControls(parent, tagManager); } private readonly Dictionary? GetControls(Control parent, MarkupTagManager tagManager) { Dictionary? tagControls = null; var nodeIndex = -1; foreach (var node in Message) { nodeIndex++; if (node.Name == null) continue; if (!tagManager.TryGetMarkupTagHandler(node.Name, _tagsAllowed, out var handler) || !handler.TryCreateControl(node, out var control)) continue; // Markup tag handler instances are shared across controls. We need to ensure that the hanlder doesn't // store state information and return the same control for each rich text entry. DebugTools.Assert(handler.TryCreateControl(node, out var other) && other != control); parent.Children.Add(control); tagControls ??= new Dictionary(); tagControls.Add(nodeIndex, control); } return tagControls; } // TODO RICH TEXT // Somehow ensure that this **has** to be called when removing rich text from some control. /// /// Remove all owned controls from their parents. /// public readonly void RemoveControls() { if (Controls == null) return; foreach (var ctrl in Controls.Values) { ctrl.Orphan(); } } /// /// Recalculate line dimensions and where it has line breaks for word wrapping. /// /// The font being used for display. /// The maximum horizontal size of the container of this entry. /// /// public RichTextEntry Update(MarkupTagManager tagManager, Font defaultFont, float maxSizeX, float uiScale, float lineHeightScale = 1) { // This method is gonna suck due to complexity. // Bear with me here. // I am so deeply sorry for the person adding stuff to this in the future. Height = defaultFont.GetHeight(uiScale); LineBreaks.Clear(); int? breakLine; var wordWrap = new WordWrap(maxSizeX); var context = new MarkupDrawingContext(); context.Font.Push(defaultFont); context.Color.Push(_defaultColor); // Go over every node. // Nodes can change the markup drawing context and return additional text. // It's also possible for nodes to return inline controls. They get treated as one large rune. var nodeIndex = -1; foreach (var node in Message) { nodeIndex++; var text = ProcessNode(tagManager, node, context); if (!context.Font.TryPeek(out var font)) font = defaultFont; // And go over every character. foreach (var rune in text.EnumerateRunes()) { if (ProcessRune(ref this, rune, out breakLine)) continue; // Uh just skip unknown characters I guess. if (!font.TryGetCharMetrics(rune, uiScale, out var metrics)) continue; if (ProcessMetric(ref this, metrics, out breakLine)) return this; } if (Controls == null || !Controls.TryGetValue(nodeIndex, out var control)) continue; control.Measure(new Vector2(maxSizeX, Height)); var desiredSize = control.DesiredPixelSize; var controlMetrics = new CharMetrics( 0, 0, desiredSize.X, desiredSize.X, desiredSize.Y); if (ProcessMetric(ref this, controlMetrics, out breakLine)) return this; } Width = wordWrap.FinalizeText(out breakLine); CheckLineBreak(ref this, breakLine); return this; bool ProcessRune(ref RichTextEntry src, Rune rune, out int? outBreakLine) { wordWrap.NextRune(rune, out breakLine, out var breakNewLine, out var skip); CheckLineBreak(ref src, breakLine); CheckLineBreak(ref src, breakNewLine); outBreakLine = breakLine; return skip; } bool ProcessMetric(ref RichTextEntry src, CharMetrics metrics, out int? outBreakLine) { wordWrap.NextMetrics(metrics, out breakLine, out var abort); CheckLineBreak(ref src, breakLine); outBreakLine = breakLine; return abort; } void CheckLineBreak(ref RichTextEntry src, int? line) { if (line is { } l) { src.LineBreaks.Add(l); if (!context.Font.TryPeek(out var font)) font = defaultFont; src.Height += GetLineHeight(font, uiScale, lineHeightScale); } } } internal readonly void HideControls() { if (Controls == null) return; foreach (var control in Controls.Values) { control.Visible = false; } } public readonly void Draw( MarkupTagManager tagManager, DrawingHandleBase handle, Font defaultFont, UIBox2 drawBox, float verticalOffset, MarkupDrawingContext context, float uiScale, float lineHeightScale = 1, TextOutline? outline = null) { if (outline is { } outlineSettings) { DrawPass( tagManager, handle, defaultFont, drawBox, verticalOffset, context, uiScale, lineHeightScale, outlineSettings, arrangeControls: false); } DrawPass( tagManager, handle, defaultFont, drawBox, verticalOffset, context, uiScale, lineHeightScale, outline: null, arrangeControls: true); } private readonly void DrawPass( MarkupTagManager tagManager, DrawingHandleBase handle, Font defaultFont, UIBox2 drawBox, float verticalOffset, MarkupDrawingContext context, float uiScale, float lineHeightScale, TextOutline? outline, bool arrangeControls) { context.Clear(); context.Color.Push(_defaultColor); context.Font.Push(defaultFont); var globalBreakCounter = 0; var lineBreakIndex = 0; var baseLine = drawBox.TopLeft + new Vector2(0, defaultFont.GetAscent(uiScale) + verticalOffset); var controlYAdvance = 0f; var hasOutline = outline.HasValue; var outlineSettings = outline.GetValueOrDefault(); var spaceRune = new Rune(' '); var nodeIndex = -1; foreach (var node in Message) { nodeIndex++; var text = ProcessNode(tagManager, node, context); if (!context.Color.TryPeek(out var color) || !context.Font.TryPeek(out var font)) { color = _defaultColor; font = defaultFont; } foreach (var rune in text.EnumerateRunes()) { bool skipSpaceBaseline = false; if (lineBreakIndex < LineBreaks.Count && LineBreaks[lineBreakIndex] == globalBreakCounter) { baseLine = new Vector2(drawBox.Left, baseLine.Y + GetLineHeight(font, uiScale, lineHeightScale) + controlYAdvance); controlYAdvance = 0; lineBreakIndex += 1; // The baseline calc is kind of messed up, the newline is After the space but the space is being drawn after doing the newline // Which means if this metric Ends on a space, the next metric will use the wrong baseline when it starts, for some reason .. if (rune == spaceRune) skipSpaceBaseline = true; } var advance = hasOutline ? font.DrawCharOutline(handle, rune, baseLine, uiScale, outlineSettings) : font.DrawChar(handle, rune, baseLine, uiScale, color); if (!skipSpaceBaseline) baseLine += new Vector2(advance, 0); globalBreakCounter += 1; } if (Controls == null || !Controls.TryGetValue(nodeIndex, out var control)) continue; var invertedScale = 1f / uiScale; if (arrangeControls) { // Controls may have been previously hidden via HideControls due to being "out-of frame". // If this ever gets replaced with RectClipContents / scissor box testing, this can be removed. control.Visible = true; control.Measure(new Vector2(Width, Height)); control.Arrange(UIBox2.FromDimensions( baseLine.X * invertedScale, (baseLine.Y - defaultFont.GetAscent(uiScale)) * invertedScale, control.DesiredSize.X, control.DesiredSize.Y )); } else { // The outline pass still needs the control's advance to place later glyphs correctly. control.Measure(new Vector2(Width, Height)); } var advanceX = control.DesiredPixelSize.X; controlYAdvance = Math.Max(0f, (control.DesiredPixelSize.Y - GetLineHeight(font, uiScale, lineHeightScale)) * invertedScale); baseLine += new Vector2(advanceX, 0); } } private readonly string ProcessNode(MarkupTagManager tagManager, MarkupNode node, MarkupDrawingContext context) { // If a nodes name is null it's a text node. if (node.Name == null) return node.Value.StringValue ?? ""; //Skip the node if there is no markup tag for it. if (!tagManager.TryGetMarkupTagHandler(node.Name, _tagsAllowed, out var tag)) return ""; if (!node.Closing) { tag.PushDrawContext(node, context); return tag.TextBefore(node); } tag.PopDrawContext(node, context); return tag.TextAfter(node); } private static int GetLineHeight(Font font, float uiScale, float lineHeightScale) { var height = font.GetLineHeight(uiScale); return (int)(height * lineHeightScale); } } }