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('-'); + } + +}