mirror of
https://github.com/space-wizards/RobustToolbox.git
synced 2026-02-15 03:30:53 +01:00
279 lines
12 KiB
C#
279 lines
12 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Text;
|
|
using JetBrains.Annotations;
|
|
using Robust.Client.Graphics;
|
|
using Robust.Client.UserInterface.Controls;
|
|
using Robust.Shared.Log;
|
|
using Robust.Shared.Maths;
|
|
using Robust.Shared.Utility;
|
|
|
|
namespace Robust.Client.UserInterface
|
|
{
|
|
/// <summary>
|
|
/// Used by <see cref="OutputPanel"/> and <see cref="RichTextLabel"/> to handle rich text layout.
|
|
/// </summary>
|
|
internal struct RichTextEntry
|
|
{
|
|
private static readonly FormattedMessage.TagColor TagBaseColor
|
|
= new(new Color(200, 200, 200));
|
|
|
|
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 readonly List<int> LineBreaks;
|
|
|
|
public RichTextEntry(FormattedMessage message)
|
|
{
|
|
Message = message;
|
|
Height = 0;
|
|
Width = 0;
|
|
LineBreaks = new List<int>();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Recalculate line dimensions and where it has line breaks for word wrapping.
|
|
/// </summary>
|
|
/// <param name="font">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 font, 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 = font.GetHeight(uiScale);
|
|
LineBreaks.Clear();
|
|
|
|
var maxUsedWidth = 0f;
|
|
// Index we put into the LineBreaks list when a line break should occur.
|
|
var breakIndexCounter = 0;
|
|
// If the CURRENT processing word ends up too long, this is the index to put a line break.
|
|
(int index, float lineSize)? wordStartBreakIndex = null;
|
|
// Word size in pixels.
|
|
var wordSizePixels = 0;
|
|
// The horizontal position of the text cursor.
|
|
var posX = 0;
|
|
var lastRune = new Rune('A');
|
|
// If a word is larger than maxSizeX, we split it.
|
|
// We need to keep track of some data to split it into two words.
|
|
(int breakIndex, int wordSizePixels)? forceSplitData = null;
|
|
// Go over every text tag.
|
|
// We treat multiple text tags as one continuous one.
|
|
// So changing color inside a single word doesn't create a word break boundary.
|
|
foreach (var tag in Message.Tags)
|
|
{
|
|
// For now we can ignore every entry that isn't a text tag because those are only color related.
|
|
// For now.
|
|
if (!(tag is FormattedMessage.TagText tagText))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var text = tagText.Text;
|
|
// And go over every character.
|
|
foreach (var rune in text.EnumerateRunes())
|
|
{
|
|
breakIndexCounter += 1;
|
|
|
|
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.");
|
|
// 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.
|
|
LineBreaks.Add(wordStartBreakIndex!.Value.index);
|
|
Height += font.GetLineHeight(uiScale);
|
|
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'))
|
|
{
|
|
LineBreaks.Add(breakIndexCounter);
|
|
Height += font.GetLineHeight(uiScale);
|
|
maxUsedWidth = Math.Max(maxUsedWidth, posX);
|
|
posX = 0;
|
|
lastRune = rune;
|
|
wordStartBreakIndex = null;
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Uh just skip unknown characters I guess.
|
|
if (!font.TryGetCharMetrics(rune, uiScale, out var metrics))
|
|
{
|
|
lastRune = rune;
|
|
continue;
|
|
}
|
|
|
|
// 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)
|
|
{
|
|
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.
|
|
return;
|
|
}
|
|
|
|
// Reset forceSplitData so that we can split again if necessary.
|
|
forceSplitData = null;
|
|
LineBreaks.Add(breakIndex);
|
|
Height += font.GetLineHeight(uiScale);
|
|
wordSizePixels -= splitWordSize;
|
|
wordStartBreakIndex = null;
|
|
maxUsedWidth = Math.Max(maxUsedWidth, maxSizeX);
|
|
posX = wordSizePixels;
|
|
}
|
|
}
|
|
|
|
lastRune = rune;
|
|
}
|
|
}
|
|
|
|
// 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.");
|
|
}
|
|
|
|
LineBreaks.Add(wordStartBreakIndex!.Value.index);
|
|
Height += font.GetLineHeight(uiScale);
|
|
maxUsedWidth = Math.Max(maxUsedWidth, wordStartBreakIndex.Value.lineSize);
|
|
}
|
|
else
|
|
{
|
|
maxUsedWidth = Math.Max(maxUsedWidth, posX);
|
|
}
|
|
|
|
Width = (int) maxUsedWidth;
|
|
}
|
|
|
|
public void Draw(
|
|
DrawingHandleScreen handle,
|
|
Font font,
|
|
UIBox2 drawBox,
|
|
float verticalOffset,
|
|
// A stack for format tags.
|
|
// This stack contains the format tag to RETURN TO when popped off.
|
|
// So when a new color tag gets hit this stack gets the previous color pushed on.
|
|
Stack<FormattedMessage.Tag> formatStack, float uiScale)
|
|
{
|
|
// The tag currently doing color.
|
|
var currentColorTag = TagBaseColor;
|
|
|
|
var globalBreakCounter = 0;
|
|
var lineBreakIndex = 0;
|
|
var baseLine = drawBox.TopLeft + new Vector2(0, font.GetAscent(uiScale) + verticalOffset);
|
|
formatStack.Clear();
|
|
foreach (var tag in Message.Tags)
|
|
{
|
|
switch (tag)
|
|
{
|
|
case FormattedMessage.TagColor tagColor:
|
|
formatStack.Push(currentColorTag);
|
|
currentColorTag = tagColor;
|
|
break;
|
|
case FormattedMessage.TagPop _:
|
|
var popped = formatStack.Pop();
|
|
switch (popped)
|
|
{
|
|
case FormattedMessage.TagColor tagColor:
|
|
currentColorTag = tagColor;
|
|
break;
|
|
default:
|
|
throw new InvalidOperationException();
|
|
}
|
|
|
|
break;
|
|
case FormattedMessage.TagText tagText:
|
|
{
|
|
var text = tagText.Text;
|
|
foreach (var rune in text.EnumerateRunes())
|
|
{
|
|
globalBreakCounter += 1;
|
|
|
|
if (lineBreakIndex < LineBreaks.Count &&
|
|
LineBreaks[lineBreakIndex] == globalBreakCounter)
|
|
{
|
|
baseLine = new Vector2(drawBox.Left, baseLine.Y + font.GetLineHeight(uiScale));
|
|
lineBreakIndex += 1;
|
|
}
|
|
|
|
var advance = font.DrawChar(handle, rune, baseLine, uiScale, currentColorTag.Color);
|
|
baseLine += new Vector2(advance, 0);
|
|
}
|
|
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
[Pure]
|
|
private static bool IsWordBoundary(Rune a, Rune b)
|
|
{
|
|
return a == new Rune(' ') || b == new Rune(' ') || a == new Rune('-') || b == new Rune('-');
|
|
}
|
|
}
|
|
}
|