Files
RobustToolbox/Robust.Client/UserInterface/RichTextEntry.cs
2021-12-20 12:38:35 +01:00

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