diff --git a/Content.Client/Chat/UI/SpeechBubble.cs b/Content.Client/Chat/UI/SpeechBubble.cs
index 91e8e5a90f..1f69de8200 100644
--- a/Content.Client/Chat/UI/SpeechBubble.cs
+++ b/Content.Client/Chat/UI/SpeechBubble.cs
@@ -1,7 +1,7 @@
using System.Numerics;
-using Content.Client.Chat.Managers;
using Content.Shared.CCVar;
using Content.Shared.Chat;
+using Content.Client.White.UserInterface.Controls;
using Robust.Client.Graphics;
using Robust.Client.UserInterface;
using Robust.Client.UserInterface.Controls;
@@ -209,7 +209,7 @@ namespace Content.Client.Chat.UI
protected override Control BuildBubble(ChatMessage message, string speechStyleClass, Color? fontColor = null)
{
- var label = new RichTextLabel
+ var label = new ChatRichTextLabel
{
MaxWidth = SpeechMaxWidth,
};
diff --git a/Content.Client/White/UserInterface/Controls/ChatRichTextEntry.cs b/Content.Client/White/UserInterface/Controls/ChatRichTextEntry.cs
new file mode 100644
index 0000000000..9796331a86
--- /dev/null
+++ b/Content.Client/White/UserInterface/Controls/ChatRichTextEntry.cs
@@ -0,0 +1,266 @@
+using System.Text;
+using Robust.Client.GameObjects;
+using Robust.Client.Graphics;
+using Robust.Client.UserInterface;
+using Robust.Client.UserInterface.RichText;
+using Robust.Shared.Collections;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Utility;
+
+namespace Content.Client.White.UserInterface.Controls;
+
+internal struct ChatRichTextEntry
+{
+
+ private static readonly Color DefaultColor = new(200, 200, 200);
+
+ private readonly MarkupTagManager _tagManager;
+
+ 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;
+
+ private readonly Dictionary _tagControls = new();
+
+ public ChatRichTextEntry(FormattedMessage message, Control parent, MarkupTagManager tagManager)
+ {
+ Message = message;
+ Height = 0;
+ Width = 0;
+ LineBreaks = default;
+ _tagManager = tagManager;
+
+ var nodeIndex = -1;
+ foreach (var node in Message.Nodes)
+ {
+ nodeIndex++;
+
+ if (node.Name == null)
+ continue;
+
+ if (!_tagManager.TryGetMarkupTag(node.Name, out var tag) || !tag.TryGetControl(node, out var control))
+ continue;
+
+ parent.Children.Add(control);
+ _tagControls.Add(nodeIndex, control);
+ }
+ }
+
+ ///
+ /// 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 void Update(Font defaultFont, float maxSizeX, float uiScale)
+ {
+ // 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.Nodes)
+ {
+ nodeIndex++;
+ var text = ProcessNode(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;
+ }
+
+ if (!_tagControls.TryGetValue(nodeIndex, out var control))
+ continue;
+
+ if (ProcessRune(ref this, new Rune(' '), out breakLine))
+ continue;
+
+ control.Measure(new Vector2(Width, 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;
+ }
+
+ Width = wordWrap.FinalizeText(out breakLine);
+ CheckLineBreak(ref this, breakLine);
+
+ bool ProcessRune(ref ChatRichTextEntry 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 ChatRichTextEntry 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 ChatRichTextEntry src, int? line)
+ {
+ if (line is { } l)
+ {
+ src.LineBreaks.Add(l);
+ if (!context.Font.TryPeek(out var font))
+ font = defaultFont;
+
+ src.Height += font.GetLineHeight(uiScale);
+ }
+ }
+ }
+
+ public readonly void Draw(
+ DrawingHandleScreen handle,
+ Font defaultFont,
+ UIBox2 drawBox,
+ float verticalOffset,
+ MarkupDrawingContext context,
+ float uiScale)
+ {
+ context.Clear();
+ context.Color.Push(DefaultColor);
+ context.Font.Push(defaultFont);
+
+ //handle.UseShader(MakeNewShader(false, 16));
+
+ var globalBreakCounter = 0;
+ var lineBreakIndex = 0;
+ var baseLine = drawBox.TopLeft + new Vector2(0, defaultFont.GetAscent(uiScale) + verticalOffset);
+ var controlYAdvance = 0;
+
+ var nodeIndex = -1;
+ foreach (var node in Message.Nodes)
+ {
+ nodeIndex++;
+ var text = ProcessNode(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 + font.GetLineHeight(uiScale) + controlYAdvance);
+ controlYAdvance = 0;
+ lineBreakIndex += 1;
+ }
+ // Костыльно
+ // Пока поставлю на 0.4. Потом надо адаптировать на 0.5
+ // MENTION: Это старый вариант обводки с добавлением задней буквы
+ //var baseLineBackground = new Vector2(
+ // baseLine.X - (font.GetLineHeight(uiScale+0.4f)/font.GetLineHeight(uiScale)),
+ // baseLine.Y + (font.GetLineHeight(uiScale+0.4f)/(font.GetLineHeight(uiScale)/2.5f))
+ //);
+ //font.DrawChar(handle, rune, baseLineBackground, uiScale+0.5f, new Color(0,0,0)); // или 0.4f
+ // MENTION2: Новый варик с шейдером. Шейдер кривой, а код грязный
+ //handle.UseShader(MakeNewShader()); // Ах да, я же шейдеры оставил в ебанинах ахах
+
+ // Также именно в этом файле пишем приколы со шрифтами.
+ // на уровне отрисовки лол.
+
+ //var sprite_icon = new SpriteSpecifier.Rsi(new ResPath("/Textures/Interface/Misc/job_icons.rsi"), "NoId");
+ //var _iconTexture = IoCManager.Resolve().EntitySysManager.GetEntitySystem().Frame0(sprite_icon);
+ var advance = font.DrawChar(handle, rune, baseLine, uiScale, color, true);
+ baseLine += new Vector2(advance, 0);
+ //handle.DrawTextureRect(_iconTexture, new UIBox2(baseLine += new Vector2(0, 5), new Vector2(6, 6)));
+ //baseLine += new Vector2(10, 0);
+
+ globalBreakCounter += 1;
+ }
+
+ if (!_tagControls.TryGetValue(nodeIndex, out var control))
+ continue;
+
+ // Почему-то не работает лол
+ //control.Position = new Vector2(baseLine.X, baseLine.Y - defaultFont.GetAscent(uiScale));
+ control.Measure(new Vector2(Width, Height));
+ var advanceX = control.DesiredPixelSize.X;
+ controlYAdvance = Math.Max(0, control.DesiredPixelSize.Y - font.GetLineHeight(uiScale));
+ baseLine += new Vector2(advanceX, 0);
+ }
+ }
+
+ // Unused shader maker
+ //private ShaderInstance MakeNewShader()
+ //{
+ // var shaderName = "SelectionOutlineBlack";
+//
+ // var instance = IoCManager.Resolve().Index(shaderName).InstanceUnique();
+ // //instance.SetParameter("outline_width", 1f);
+ // //instance.SetParameter("SCREEN_TEXTURE", viewport.RenderTarget.Texture);
+ // return instance;
+ //}
+
+ private readonly string ProcessNode(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.TryGetMarkupTag(node.Name, out var tag))
+ return "";
+
+ if (!node.Closing)
+ {
+ tag.PushDrawContext(node, context);
+ return tag.TextBefore(node);
+ }
+
+ tag.PopDrawContext(node, context);
+ return tag.TextAfter(node);
+ }
+}
diff --git a/Content.Client/White/UserInterface/Controls/ChatRichTextLabel.cs b/Content.Client/White/UserInterface/Controls/ChatRichTextLabel.cs
new file mode 100644
index 0000000000..64f3f92b1e
--- /dev/null
+++ b/Content.Client/White/UserInterface/Controls/ChatRichTextLabel.cs
@@ -0,0 +1,72 @@
+using System.Diagnostics.Contracts;
+using Robust.Client.Graphics;
+using Robust.Client.UserInterface;
+using Robust.Client.UserInterface.RichText;
+using Robust.Shared.Utility;
+
+namespace Content.Client.White.UserInterface.Controls;
+
+public class ChatRichTextLabel : Control
+{
+ [Dependency] private readonly MarkupTagManager _tagManager = default!;
+
+ private FormattedMessage? _message;
+ private ChatRichTextEntry _entry;
+
+ public ChatRichTextLabel()
+ {
+ IoCManager.InjectDependencies(this);
+ }
+
+ public void SetMessage(FormattedMessage message)
+ {
+ _message = message;
+ _entry = new ChatRichTextEntry(_message, this, _tagManager);
+ InvalidateMeasure();
+ }
+
+ public void SetMessage(string message)
+ {
+ var msg = new FormattedMessage();
+ msg.AddText(message);
+ SetMessage(msg);
+ }
+
+ public string? GetMessage() => _message?.ToMarkup();
+
+ protected override Vector2 MeasureOverride(Vector2 availableSize)
+ {
+ if (_message == null)
+ {
+ return Vector2.Zero;
+ }
+
+ var font = _getFont();
+ _entry.Update(font, availableSize.X * UIScale, UIScale);
+
+ return (_entry.Width / UIScale, _entry.Height / UIScale);
+ }
+
+ protected override void Draw(DrawingHandleScreen handle)
+ {
+ base.Draw(handle);
+
+ if (_message == null)
+ {
+ return;
+ }
+
+ _entry.Draw(handle, _getFont(), SizeBox, 0, new MarkupDrawingContext(), UIScale);
+ }
+
+ [Pure]
+ private Font _getFont()
+ {
+ if (TryGetStyleProperty("font", out var font))
+ {
+ return font;
+ }
+
+ return UserInterfaceManager.ThemeDefaults.DefaultFont;
+ }
+}
diff --git a/Content.Client/White/UserInterface/Controls/WordWrap.cs b/Content.Client/White/UserInterface/Controls/WordWrap.cs
new file mode 100644
index 0000000000..b9c3514513
--- /dev/null
+++ b/Content.Client/White/UserInterface/Controls/WordWrap.cs
@@ -0,0 +1,170 @@
+using System.Diagnostics.Contracts;
+using System.Text;
+using Robust.Client.Graphics;
+using Robust.Shared.Utility;
+
+namespace Content.Client.White.UserInterface.Controls;
+
+internal struct WordWrap
+{
+ private readonly float _maxSizeX;
+
+ public float MaxUsedWidth;
+ // Index we put into the LineBreaks list when a line break should occur.
+ public int BreakIndexCounter;
+ public int NextBreakIndexCounter;
+ // If the CURRENT processing word ends up too long, this is the index to put a line break.
+ public (int index, float lineSize)? WordStartBreakIndex;
+ // Word size in pixels.
+ public int WordSizePixels;
+ // The horizontal position of the text cursor.
+ public int PosX;
+ public Rune LastRune;
+ // If a word is larger than maxSizeX, we split it.
+ // We need to keep track of some data to split it into two words.
+ public (int breakIndex, int wordSizePixels)? ForceSplitData = null;
+
+ public WordWrap(float maxSizeX)
+ {
+ this = default;
+ _maxSizeX = maxSizeX;
+ LastRune = new Rune('A');
+ }
+
+ public void NextRune(Rune rune, out int? breakLine, out int? breakNewLine, out bool skip)
+ {
+ BreakIndexCounter = NextBreakIndexCounter;
+ NextBreakIndexCounter += rune.Utf16SequenceLength;
+
+ breakLine = null;
+ breakNewLine = null;
+ skip = false;
+
+ if (IsWordBoundary(LastRune, rune) || rune == new Rune('\n'))
+ {
+ // Word boundary means we know where the word ends.
+ if (PosX > _maxSizeX && LastRune != new Rune(' '))
+ {
+ DebugTools.Assert(WordStartBreakIndex.HasValue,
+ "wordStartBreakIndex can only be null if the word begins at a new line, in which case this branch shouldn't be reached as the word would be split due to being longer than a single line.");
+ //Ensure the assert had a chance to run and then just return
+ if (!WordStartBreakIndex.HasValue)
+ return;
+
+ // We ran into a word boundary and the word is too big to fit the previous line.
+ // So we insert the line break BEFORE the last word.
+ breakLine = WordStartBreakIndex!.Value.index;
+ MaxUsedWidth = Math.Max(MaxUsedWidth, WordStartBreakIndex.Value.lineSize);
+ PosX = WordSizePixels;
+ }
+
+ // Start a new word since we hit a word boundary.
+ //wordSize = 0;
+ WordSizePixels = 0;
+ WordStartBreakIndex = (BreakIndexCounter, PosX);
+ ForceSplitData = null;
+
+ // Just manually handle newlines.
+ if (rune == new Rune('\n'))
+ {
+ MaxUsedWidth = Math.Max(MaxUsedWidth, PosX);
+ PosX = 0;
+ WordStartBreakIndex = null;
+ skip = true;
+ breakNewLine = BreakIndexCounter;
+ }
+ }
+
+ LastRune = rune;
+ }
+
+ public void NextMetrics(in CharMetrics metrics, out int? breakLine, out bool abort)
+ {
+ abort = false;
+ breakLine = null;
+
+ // Increase word size and such with the current character.
+ var oldWordSizePixels = WordSizePixels;
+ WordSizePixels += metrics.Advance;
+ // TODO: Theoretically, does it make sense to break after the glyph's width instead of its advance?
+ // It might result in some more tight packing but I doubt it'd be noticeable.
+ // Also definitely even more complex to implement.
+ PosX += metrics.Advance;
+
+ if (PosX <= _maxSizeX)
+ return;
+
+ if (!ForceSplitData.HasValue)
+ {
+ ForceSplitData = (BreakIndexCounter, oldWordSizePixels);
+ }
+
+ // Oh hey we get to break a word that doesn't fit on a single line.
+ if (WordSizePixels > _maxSizeX)
+ {
+ var (breakIndex, splitWordSize) = ForceSplitData.Value;
+ if (splitWordSize == 0)
+ {
+ // Happens if there's literally not enough space for a single character so uh...
+ // Yeah just don't.
+ abort = true;
+ return;
+ }
+
+ // Reset forceSplitData so that we can split again if necessary.
+ ForceSplitData = null;
+ breakLine = breakIndex;
+ WordSizePixels -= splitWordSize;
+ WordStartBreakIndex = null;
+ MaxUsedWidth = Math.Max(MaxUsedWidth, _maxSizeX);
+ PosX = WordSizePixels;
+ }
+ }
+
+ public int FinalizeText(out int? breakLine)
+ {
+ // This needs to happen because word wrapping doesn't get checked for the last word.
+ if (PosX > _maxSizeX)
+ {
+ if (!WordStartBreakIndex.HasValue)
+ {
+ Logger.Error(
+ "Assert fail inside RichTextEntry.Update, " +
+ "wordStartBreakIndex is null on method end w/ word wrap required. " +
+ "Dumping relevant stuff. Send this to PJB.");
+ // Logger.Error($"Message: {Message}");
+ Logger.Error($"maxSizeX: {_maxSizeX}");
+ Logger.Error($"maxUsedWidth: {MaxUsedWidth}");
+ Logger.Error($"breakIndexCounter: {BreakIndexCounter}");
+ Logger.Error("wordStartBreakIndex: null (duh)");
+ Logger.Error($"wordSizePixels: {WordSizePixels}");
+ Logger.Error($"posX: {PosX}");
+ Logger.Error($"lastChar: {LastRune}");
+ Logger.Error($"forceSplitData: {ForceSplitData}");
+ // Logger.Error($"LineBreaks: {string.Join(", ", LineBreaks)}");
+
+ throw new Exception(
+ "wordStartBreakIndex can only be null if the word begins at a new line," +
+ "in which case this branch shouldn't be reached as" +
+ "the word would be split due to being longer than a single line.");
+ }
+
+ breakLine = WordStartBreakIndex.Value.index;
+ MaxUsedWidth = Math.Max(MaxUsedWidth, WordStartBreakIndex.Value.lineSize);
+ }
+ else
+ {
+ breakLine = null;
+ MaxUsedWidth = Math.Max(MaxUsedWidth, PosX);
+ }
+
+ return (int)MaxUsedWidth;
+ }
+
+ [Pure]
+ private static bool IsWordBoundary(Rune a, Rune b)
+ {
+ return a == new Rune(' ') || b == new Rune(' ') || a == new Rune('-') || b == new Rune('-');
+ }
+
+}