Merge pull request #133 from frosty-dev/speech-bubble-elements-refactor
[Feat/Tweak] Рефактор элементов рич текста для speechbubble
This commit is contained in:
@@ -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,
|
||||
};
|
||||
|
||||
266
Content.Client/White/UserInterface/Controls/ChatRichTextEntry.cs
Normal file
266
Content.Client/White/UserInterface/Controls/ChatRichTextEntry.cs
Normal file
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// The vertical size of this entry, in pixels.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
private readonly Dictionary<int, Control> _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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <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>
|
||||
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<IEntityManager>().EntitySysManager.GetEntitySystem<SpriteSystem>().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<IPrototypeManager>().Index<ShaderPrototype>(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);
|
||||
}
|
||||
}
|
||||
@@ -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>("font", out var font))
|
||||
{
|
||||
return font;
|
||||
}
|
||||
|
||||
return UserInterfaceManager.ThemeDefaults.DefaultFont;
|
||||
}
|
||||
}
|
||||
170
Content.Client/White/UserInterface/Controls/WordWrap.cs
Normal file
170
Content.Client/White/UserInterface/Controls/WordWrap.cs
Normal file
@@ -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('-');
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user