file scoped. I pushed this to the wrong branch initially because I'm stupid

This commit is contained in:
Princess Cheeseballs
2025-12-09 03:29:06 -08:00
parent fdcc4c539b
commit 9a911caa77

View File

@@ -9,317 +9,316 @@ using Robust.Shared.Collections;
using Robust.Shared.Maths;
using Robust.Shared.Utility;
namespace Robust.Client.UserInterface
namespace Robust.Client.UserInterface;
/// <summary>
/// Used by <see cref="OutputPanel"/> and <see cref="RichTextLabel"/> to handle rich text layout.
/// Note that if this text is ever removed or modified without removing the owning control,
/// then <see cref="RemoveControls"/> should be called to ensure that any controls that were added by this
/// entry are also removed.
/// </summary>
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 = DefaultTags;
public readonly FormattedMessage Message;
/// <summary>
/// Used by <see cref="OutputPanel"/> and <see cref="RichTextLabel"/> to handle rich text layout.
/// Note that if this text is ever removed or modified without removing the owning control,
/// then <see cref="RemoveControls"/> should be called to ensure that any controls that were added by this
/// entry are also removed.
/// The vertical size of this entry, in pixels.
/// </summary>
internal struct RichTextEntry
public int Height;
/// <summary>
/// The horizontal size of this entry, in pixels.
/// </summary>
public int Width;
/// <summary>
/// The combined text indices in the message's text tags to put line breaks.
/// </summary>
public ValueList<int> LineBreaks;
public readonly Dictionary<int, Control>? Controls;
public RichTextEntry(FormattedMessage message, Control parent, MarkupTagManager tagManager, Color? defaultColor = null)
{
public static readonly Type[] DefaultTags =
[
typeof(BoldItalicTag),
typeof(BoldTag),
typeof(BulletTag),
typeof(ColorTag),
typeof(HeadingTag),
typeof(ItalicTag)
];
Message = message;
Height = 0;
Width = 0;
LineBreaks = default;
_defaultColor = defaultColor ?? new(200, 200, 200);
Controls = GetControls(parent, tagManager);
private readonly Color _defaultColor;
private readonly Type[]? _tagsAllowed = DefaultTags;
// Default sane tags
_tagsAllowed = DefaultTags;
}
public readonly FormattedMessage Message;
public RichTextEntry(FormattedMessage message, Control parent, MarkupTagManager tagManager, Type[]? tagsAllowed = null, Color? defaultColor = null)
{
Message = message;
Height = 0;
Width = 0;
LineBreaks = default;
_defaultColor = defaultColor ?? new(200, 200, 200);
_tagsAllowed = tagsAllowed;
Controls = GetControls(parent, tagManager);
}
/// <summary>
/// The vertical size of this entry, in pixels.
/// </summary>
public int Height;
private readonly Dictionary<int, Control>? GetControls(Control parent, MarkupTagManager tagManager)
{
Dictionary<int, Control>? tagControls = null;
var nodeIndex = -1;
/// <summary>
/// The horizontal size of this entry, in pixels.
/// </summary>
public int Width;
/// <summary>
/// The combined text indices in the message's text tags to put line breaks.
/// </summary>
public ValueList<int> LineBreaks;
public readonly Dictionary<int, Control>? Controls;
public RichTextEntry(FormattedMessage message, Control parent, MarkupTagManager tagManager, Color? defaultColor = null)
foreach (var node in Message)
{
Message = message;
Height = 0;
Width = 0;
LineBreaks = default;
_defaultColor = defaultColor ?? new(200, 200, 200);
Controls = GetControls(parent, tagManager);
nodeIndex++;
// Default sane tags
_tagsAllowed = DefaultTags;
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<int, Control>();
tagControls.Add(nodeIndex, control);
}
public RichTextEntry(FormattedMessage message, Control parent, MarkupTagManager tagManager, Type[]? tagsAllowed = null, Color? defaultColor = null)
return tagControls;
}
// TODO RICH TEXT
// Somehow ensure that this **has** to be called when removing rich text from some control.
/// <summary>
/// Remove all owned controls from their parents.
/// </summary>
public readonly void RemoveControls()
{
if (Controls == null)
return;
foreach (var ctrl in Controls.Values)
{
Message = message;
Height = 0;
Width = 0;
LineBreaks = default;
_defaultColor = defaultColor ?? new(200, 200, 200);
_tagsAllowed = tagsAllowed;
Controls = GetControls(parent, tagManager);
ctrl.Orphan();
}
}
private readonly Dictionary<int, Control>? GetControls(Control parent, MarkupTagManager tagManager)
/// <summary>
/// Recalculate line dimensions and where it has line breaks for word wrapping.
/// </summary>
/// <param name="defaultFont">The font being used for display.</param>
/// <param name="maxSizeX">The maximum horizontal size of the container of this entry.</param>
/// <param name="uiScale"></param>
/// <param name="lineHeightScale"></param>
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)
{
Dictionary<int, Control>? tagControls = null;
var nodeIndex = -1;
nodeIndex++;
var text = ProcessNode(tagManager, node, context);
foreach (var node in Message)
if (!context.Font.TryPeek(out var font))
font = defaultFont;
// And go over every character.
foreach (var rune in text.EnumerateRunes())
{
nodeIndex++;
if (node.Name == null)
if (ProcessRune(ref this, rune, out breakLine))
continue;
if (!tagManager.TryGetMarkupTagHandler(node.Name, _tagsAllowed, out var handler) || !handler.TryCreateControl(node, out var control))
// Uh just skip unknown characters I guess.
if (!font.TryGetCharMetrics(rune, uiScale, out var metrics))
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<int, Control>();
tagControls.Add(nodeIndex, control);
}
return tagControls;
}
// TODO RICH TEXT
// Somehow ensure that this **has** to be called when removing rich text from some control.
/// <summary>
/// Remove all owned controls from their parents.
/// </summary>
public readonly void RemoveControls()
{
if (Controls == null)
return;
foreach (var ctrl in Controls.Values)
{
ctrl.Orphan();
}
}
/// <summary>
/// Recalculate line dimensions and where it has line breaks for word wrapping.
/// </summary>
/// <param name="defaultFont">The font being used for display.</param>
/// <param name="maxSizeX">The maximum horizontal size of the container of this entry.</param>
/// <param name="uiScale"></param>
/// <param name="lineHeightScale"></param>
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))
if (ProcessMetric(ref this, metrics, out breakLine))
return this;
}
Width = wordWrap.FinalizeText(out breakLine);
CheckLineBreak(ref this, breakLine);
if (Controls == null || !Controls.TryGetValue(nodeIndex, out var control))
continue;
return this;
control.Measure(new Vector2(maxSizeX, Height));
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;
}
var desiredSize = control.DesiredPixelSize;
var controlMetrics = new CharMetrics(
0, 0,
desiredSize.X,
desiredSize.X,
desiredSize.Y);
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);
}
}
if (ProcessMetric(ref this, controlMetrics, out breakLine))
return this;
}
internal readonly void HideControls()
{
if (Controls == null)
return;
Width = wordWrap.FinalizeText(out breakLine);
CheckLineBreak(ref this, breakLine);
foreach (var control in Controls.Values)
{
control.Visible = false;
}
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;
}
public readonly void Draw(
MarkupTagManager tagManager,
DrawingHandleBase handle,
Font defaultFont,
UIBox2 drawBox,
float verticalOffset,
MarkupDrawingContext context,
float uiScale,
float lineHeightScale = 1)
bool ProcessMetric(ref RichTextEntry src, CharMetrics metrics, out int? outBreakLine)
{
context.Clear();
context.Color.Push(_defaultColor);
context.Font.Push(defaultFont);
wordWrap.NextMetrics(metrics, out breakLine, out var abort);
CheckLineBreak(ref src, breakLine);
outBreakLine = breakLine;
return abort;
}
var globalBreakCounter = 0;
var lineBreakIndex = 0;
var baseLine = drawBox.TopLeft + new Vector2(0, defaultFont.GetAscent(uiScale) + verticalOffset);
var controlYAdvance = 0f;
var nodeIndex = -1;
foreach (var node in Message)
void CheckLineBreak(ref RichTextEntry src, int? line)
{
if (line is { } l)
{
nodeIndex++;
var text = ProcessNode(tagManager, node, context);
if (!context.Color.TryPeek(out var color) || !context.Font.TryPeek(out var font))
{
color = _defaultColor;
src.LineBreaks.Add(l);
if (!context.Font.TryPeek(out var font))
font = defaultFont;
}
foreach (var rune in text.EnumerateRunes())
{
if (lineBreakIndex < LineBreaks.Count &&
LineBreaks[lineBreakIndex] == globalBreakCounter)
{
baseLine = new Vector2(drawBox.Left, baseLine.Y + GetLineHeight(font, uiScale, lineHeightScale) + controlYAdvance);
controlYAdvance = 0;
lineBreakIndex += 1;
}
var advance = font.DrawChar(handle, rune, baseLine, uiScale, color);
baseLine += new Vector2(advance, 0);
globalBreakCounter += 1;
}
if (Controls == null || !Controls.TryGetValue(nodeIndex, out var control))
continue;
// 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;
var invertedScale = 1f / uiScale;
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
));
var advanceX = control.DesiredPixelSize.X;
controlYAdvance = Math.Max(0f, (control.DesiredPixelSize.Y - GetLineHeight(font, uiScale, lineHeightScale)) * invertedScale);
baseLine += new Vector2(advanceX, 0);
src.Height += GetLineHeight(font, uiScale, lineHeightScale);
}
}
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);
}
}
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)
{
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 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())
{
if (lineBreakIndex < LineBreaks.Count &&
LineBreaks[lineBreakIndex] == globalBreakCounter)
{
baseLine = new Vector2(drawBox.Left, baseLine.Y + GetLineHeight(font, uiScale, lineHeightScale) + controlYAdvance);
controlYAdvance = 0;
lineBreakIndex += 1;
}
var advance = font.DrawChar(handle, rune, baseLine, uiScale, color);
baseLine += new Vector2(advance, 0);
globalBreakCounter += 1;
}
if (Controls == null || !Controls.TryGetValue(nodeIndex, out var control))
continue;
// 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;
var invertedScale = 1f / uiScale;
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
));
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);
}
}