Revert Rich Text (#2363)

This commit is contained in:
Paul Ritter
2021-12-20 12:38:35 +01:00
committed by GitHub
parent c64c1aca5b
commit 338a2831ee
26 changed files with 607 additions and 1257 deletions

View File

@@ -557,12 +557,12 @@ namespace Robust.Client.Console.Commands
vBox.AddChild(tree);
var rich = new RichTextLabel();
var message = new FormattedMessage.Builder();
var message = new FormattedMessage();
message.AddText("Foo\n");
message.PushColor(Color.Red);
message.AddText("Bar");
message.Pop();
rich.SetMessage(message.Build());
rich.SetMessage(message);
vBox.AddChild(rich);
var itemList = new ItemList();

View File

@@ -83,7 +83,7 @@ namespace Robust.Client.Console
{
MouseFilter = MouseFilterMode.Stop;
Result = result;
var compl = new FormattedMessage.Builder();
var compl = new FormattedMessage();
var dim = Color.FromHsl((0f, 0f, 0.8f, 1f));
// warning: ew ahead
@@ -120,7 +120,7 @@ namespace Robust.Client.Console
compl.PushColor(Color.LightSlateGray);
compl.AddText(Result.InlineDescription);
}
SetMessage(compl.Build());
SetMessage(compl);
}
}
}

View File

@@ -95,11 +95,11 @@ namespace Robust.Client.Console
_linesEntered = 0;
// Echo entered script.
var echoMessage = new FormattedMessage.Builder();
var echoMessage = new FormattedMessage();
echoMessage.PushColor(Color.FromHex("#D4D4D4"));
echoMessage.AddText("> ");
echoMessage.AddMessage(response.Echo);
OutputPanel.AddMessage(echoMessage.Build());
OutputPanel.AddMessage(echoMessage);
OutputPanel.AddMessage(response.Response);

View File

@@ -128,12 +128,12 @@ namespace Robust.Client.Console
newScript.Compile();
// Echo entered script.
var echoMessage = new FormattedMessage.Builder();
var echoMessage = new FormattedMessage();
echoMessage.PushColor(Color.FromHex("#D4D4D4"));
echoMessage.AddText("> ");
ScriptInstanceShared.AddWithSyntaxHighlighting(newScript, echoMessage, code, _highlightWorkspace);
OutputPanel.AddMessage(echoMessage.Build());
OutputPanel.AddMessage(echoMessage);
try
{
@@ -148,7 +148,7 @@ namespace Robust.Client.Console
}
catch (CompilationErrorException e)
{
var msg = new FormattedMessage.Builder();
var msg = new FormattedMessage();
msg.PushColor(Color.Crimson);
@@ -158,7 +158,7 @@ namespace Robust.Client.Console
msg.AddText("\n");
}
OutputPanel.AddMessage(msg.Build());
OutputPanel.AddMessage(msg);
OutputPanel.AddText(">");
PromptAutoImports(e.Diagnostics, code);
@@ -167,16 +167,16 @@ namespace Robust.Client.Console
if (_state.Exception != null)
{
var msg = new FormattedMessage.Builder();
var msg = new FormattedMessage();
msg.PushColor(Color.Crimson);
msg.AddText(CSharpObjectFormatter.Instance.FormatException(_state.Exception));
OutputPanel.AddMessage(msg.Build());
OutputPanel.AddMessage(msg);
}
else if (ScriptInstanceShared.HasReturnValue(newScript))
{
var msg = new FormattedMessage.Builder();
var msg = new FormattedMessage();
msg.AddText(ScriptInstanceShared.SafeFormat(_state.ReturnValue));
OutputPanel.AddMessage(msg.Build());
OutputPanel.AddMessage(msg);
}
OutputPanel.AddText(">");

View File

@@ -1,185 +0,0 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using Robust.Shared.Utility;
using Robust.Client.ResourceManagement;
namespace Robust.Client.Graphics;
/// <summary>
/// Stores a single style (Bold, Italic, Monospace), or any combination thereof.
/// </summary>
public record FontVariant (FontStyle Style, FontResource[] Resource)
{
public virtual Font ToFont(byte size)
{
if (Resource.Length == 1)
return new VectorFont(Resource[0], size);
var fs = new Font[Resource.Length];
for (var i = 0; i < Resource.Length; i++)
fs[i] = new VectorFont(Resource[i], size);
return new StackedFont(fs);
}
};
internal record DummyVariant(FontStyle fs) : FontVariant(fs, new FontResource[0])
{
public override Font ToFont(byte size) => new DummyFont();
};
public record FontClass
(
string Id,
FontStyle Style,
FontSize Size
);
/// <summary>
/// Manages font-based bookkeeping across a single stylesheet.
/// </summary>
public interface IFontLibrary
{
FontClass Default { get; }
/// <summary>Associates a name to a set of font resources.</summary>
void AddFont(string name, params FontVariant[] variants);
/// <summary>Sets a standard size which can be reused across the Font Library.</summary>
void SetStandardSize(ushort number, byte size);
/// <summary>Sets a standard style which can be reused across the Font Library.</summary>
void SetStandardStyle(ushort number, string name, FontStyle style);
/// <summary>
/// Returns a fancy handle in to the library.
/// The handle keeps track of relative changes to <paramref name="fst"/> and <paramref name="fsz"/>.
/// </summary>
IFontLibrarian StartFont(string id, FontStyle fst, FontSize fsz);
IFontLibrarian StartFont(FontClass? fclass = default) =>
StartFont(
(fclass ?? Default).Id,
(fclass ?? Default).Style,
(fclass ?? Default).Size
);
}
/// <summary>
/// Acts as a handle in to an <seealso cref="IFontLibrary"/>.
/// </summary>
public interface IFontLibrarian
{
Font Current { get; }
Font Update(FontStyle fst, FontSize fsz);
}
public class FontLibrary : IFontLibrary
{
public FontClass Default { get; set; }
public FontLibrary(FontClass def)
{
Default = def;
}
private Dictionary<string, FontVariant[]> _styles = new();
private Dictionary<FontStyle, (string, FontStyle)> _standardSt = new();
private Dictionary<FontSize, byte> _standardSz = new();
void IFontLibrary.AddFont(string name, params FontVariant[] variants) =>
_styles[name] = variants;
IFontLibrarian IFontLibrary.StartFont(string id, FontStyle fst, FontSize fsz) =>
new FontLibrarian(this, id, fst, fsz);
void IFontLibrary.SetStandardStyle(ushort number, string name, FontStyle style) =>
_standardSt[(FontStyle) number | FontStyle.Standard] = (name, style);
void IFontLibrary.SetStandardSize(ushort number, byte size) =>
_standardSz[(FontSize) number | FontSize.Standard] = size;
private FontVariant lookup(string id, FontStyle fst)
{
if (fst.HasFlag(FontStyle.Standard))
(id, fst) = _standardSt[fst];
FontVariant? winner = default;
foreach (var vr in _styles[id])
{
var winfst = winner?.Style ?? ((FontStyle) 0);
// Since the "style" flags are a bitfield, we can just see which one has more bits.
// More bits == closer to the desired font style. Free fallback!
// Variant's bit count
var vc = BitOperations.PopCount((ulong) (vr.Style & fst));
// Winner's bit count
var wc = BitOperations.PopCount((ulong) (winfst & fst));
if (winner is null || vc > wc)
winner = vr;
}
if (winner is null)
throw new Exception($"no matching font style ({id}, {fst})");
return winner;
}
private byte lookupSz(FontSize sz)
{
if (sz.HasFlag(FontSize.RelMinus) || sz.HasFlag(FontSize.RelPlus))
throw new Exception("can't look up a relative font through a library; get a Librarian first");
if (sz.HasFlag(FontSize.Standard))
return _standardSz[sz];
return (byte) sz;
}
class FontLibrarian : IFontLibrarian
{
public Font Current => _current;
private Font _current;
private FontLibrary _lib;
private string _id;
private FontStyle _fst;
private FontSize _fsz;
public FontLibrarian(FontLibrary lib, string id, FontStyle fst, FontSize fsz)
{
_id = id;
_fst = fst;
_fsz = fsz;
_lib = lib;
// Actual font entry
var f = lib.lookup(id, fst);
// Real size
var rsz = (byte) lib.lookupSz(fsz);
_current = f.ToFont(rsz);
}
Font IFontLibrarian.Update(FontStyle fst, FontSize fsz)
{
var f = _lib.lookup(_id, fst);
byte rsz = (byte) _fsz;
var msk = (byte) fsz & 0b0000_1111;
if (fsz.HasFlag(FontSize.Standard))
rsz = _lib.lookupSz(fsz);
else if (fsz.HasFlag(FontSize.RelPlus))
rsz = (byte) (((byte) _fsz) + msk);
else if (fsz.HasFlag(FontSize.RelMinus))
rsz = (byte) (((byte) _fsz) - msk);
_fsz = (FontSize) rsz;
_fst = fst;
return _current = f.ToFont((byte) rsz);
}
}
}

View File

@@ -23,7 +23,7 @@ namespace Robust.Client.Log
if (sawmillName == "CON")
return;
var formatted = new FormattedMessage.Builder();
var formatted = new FormattedMessage(8);
var robustLevel = message.Level.ToRobust();
formatted.PushColor(Color.DarkGray);
formatted.AddText("[");
@@ -32,15 +32,13 @@ namespace Robust.Client.Log
formatted.Pop();
formatted.AddText($"] {sawmillName}: ");
formatted.Pop();
formatted.PushColor(Color.LightGray);
formatted.AddText(message.RenderMessage());
formatted.Pop();
if (message.Exception != null)
{
formatted.AddText("\n");
formatted.AddText(message.Exception.ToString());
}
Console.AddFormattedLine(formatted.Build());
Console.AddFormattedLine(formatted);
}
private static Color LogLevelToColor(LogLevel level)

View File

@@ -235,17 +235,12 @@ namespace Robust.Client.UserInterface.Controls
{
get
{
TryGetStyleProperty<FontClass>("font", out var font);
if (TryGetStyleProperty<IFontLibrary>("font-library", out var flib))
if (TryGetStyleProperty<Font>("font", out var font))
{
return flib.StartFont(font).Current;
return font;
}
return UserInterfaceManager
.ThemeDefaults
.DefaultFontLibrary
.StartFont(font)
.Current;
return UserInterfaceManager.ThemeDefaults.DefaultFont;
}
}

View File

@@ -83,10 +83,9 @@ namespace Robust.Client.UserInterface.Controls
return FontOverride;
}
TryGetStyleProperty<FontClass>(StylePropertyFont, out var font);
if (TryGetStyleProperty<IFontLibrary>("font-library", out var flib))
if (TryGetStyleProperty<Font>(StylePropertyFont, out var font))
{
return flib.StartFont(font).Current;
return font;
}
return UserInterfaceManager.ThemeDefaults.LabelFont;

View File

@@ -667,10 +667,9 @@ namespace Robust.Client.UserInterface.Controls
[Pure]
private Font _getFont()
{
TryGetStyleProperty<FontClass>("font", out var font);
if (TryGetStyleProperty<IFontLibrary>("font-library", out var flib))
if (TryGetStyleProperty<Font>("font", out var font))
{
return flib.StartFont(font).Current;
return font;
}
return UserInterfaceManager.ThemeDefaults.DefaultFont;

View File

@@ -17,6 +17,7 @@ namespace Robust.Client.UserInterface.Controls
private bool _isAtBottom = true;
private int _totalContentHeight;
private bool _firstLine = true;
private StyleBox? _styleBoxOverride;
private VScrollBar _scrollBar;
@@ -49,37 +50,54 @@ namespace Robust.Client.UserInterface.Controls
public void Clear()
{
_firstLine = true;
_entries.Clear();
_totalContentHeight = 0;
_scrollBar.MaxValue = Math.Max(_scrollBar.Page, _totalContentHeight);
_scrollBar.Value = 0;
_invalidateEntries();
}
public void RemoveEntry(Index index)
{
var entry = _entries[index];
_entries.RemoveAt(index.GetOffset(_entries.Count));
var font = _getFont();
_totalContentHeight -= entry.Height + font.GetLineSeparation(UIScale);
if (_entries.Count == 0)
{
Clear();
}
_invalidateEntries();
_scrollBar.MaxValue = Math.Max(_scrollBar.Page, _totalContentHeight);
}
public void AddText(string text)
{
var msg = new FormattedMessage.Builder();
var msg = new FormattedMessage();
msg.AddText(text);
AddMessage(msg.Build());
AddMessage(msg);
}
public void AddMessage(FormattedMessage message)
{
var entry = new RichTextEntry(message);
_entries.Add(entry);
entry.Update(_getFont(), _getContentBox().Width, UIScale);
_invalidateEntries();
_entries.Add(entry);
var font = _getFont();
_totalContentHeight += entry.Height;
if (_firstLine)
{
_firstLine = false;
}
else
{
_totalContentHeight += font.GetLineSeparation(UIScale);
}
_scrollBar.MaxValue = Math.Max(_scrollBar.Page, _totalContentHeight);
if (_isAtBottom && ScrollFollowing)
{
_scrollBar.MoveToEnd();
@@ -97,17 +115,22 @@ namespace Robust.Client.UserInterface.Controls
base.Draw(handle);
var style = _getStyleBox();
var flib = _getFontLib();
var font = _getFont();
style?.Draw(handle, PixelSizeBox);
var contentBox = _getContentBox();
var entryOffset = -_scrollBar.Value;
// 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.
var formatStack = new Stack<FormattedMessage.Tag>(2);
foreach (var entry in _entries)
{
if (entryOffset + entry.Height < 0)
{
entryOffset += entry.Height;
entryOffset += entry.Height + font.GetLineSeparation(UIScale);
continue;
}
@@ -116,9 +139,9 @@ namespace Robust.Client.UserInterface.Controls
break;
}
entry.Draw(handle, flib, contentBox, entryOffset, UIScale, _getFontColor());
entry.Draw(handle, font, contentBox, entryOffset, formatStack, UIScale);
entryOffset += entry.Height;
entryOffset += entry.Height + font.GetLineSeparation(UIScale);
}
}
@@ -152,14 +175,14 @@ namespace Robust.Client.UserInterface.Controls
private void _invalidateEntries()
{
_totalContentHeight = 0;
var font = _getFontLib();
var font = _getFont();
var sizeX = _getContentBox().Width;
for (var i = 0; i < _entries.Count; i++)
{
var entry = _entries[i];
entry.Update(font, sizeX, UIScale);
_entries[i] = entry;
_totalContentHeight += entry.Height;
_totalContentHeight += entry.Height + font.GetLineSeparation(UIScale);
}
_scrollBar.MaxValue = Math.Max(_scrollBar.Page, _totalContentHeight);
@@ -169,33 +192,15 @@ namespace Robust.Client.UserInterface.Controls
}
}
[System.Diagnostics.Contracts.Pure]
private IFontLibrary _getFontLib()
{
if (TryGetStyleProperty<IFontLibrary>("font-library", out var flib))
return flib;
return UserInterfaceManager
.ThemeDefaults
.DefaultFontLibrary;
}
[System.Diagnostics.Contracts.Pure]
private Font _getFont()
{
TryGetStyleProperty<FontClass>("font", out var fclass);
return _getFontLib().StartFont(fclass).Current;
}
if (TryGetStyleProperty<Font>("font", out var font))
{
return font;
}
[System.Diagnostics.Contracts.Pure]
private Color _getFontColor()
{
if (TryGetStyleProperty<Color>("font-color", out var fc))
return fc;
// From Robust.Client/UserInterface/RichTextEntry.cs#L19
// at 33008a2bce0cc4755b18b12edfaf5b6f1f87fdd9
return new Color(200, 200, 200);
return UserInterfaceManager.ThemeDefaults.DefaultFont;
}
[System.Diagnostics.Contracts.Pure]
@@ -213,7 +218,8 @@ namespace Robust.Client.UserInterface.Controls
[System.Diagnostics.Contracts.Pure]
private int _getScrollSpeed()
{
return _getFont().GetLineHeight(UIScale) * 2;
var font = _getFont();
return font.GetLineHeight(UIScale) * 2;
}
[System.Diagnostics.Contracts.Pure]

View File

@@ -1,4 +1,5 @@
using JetBrains.Annotations;
using System.Collections.Generic;
using JetBrains.Annotations;
using Robust.Client.Graphics;
using Robust.Shared.Maths;
using Robust.Shared.Utility;
@@ -19,9 +20,9 @@ namespace Robust.Client.UserInterface.Controls
public void SetMessage(string message)
{
var msg = new FormattedMessage.Builder();
var msg = new FormattedMessage();
msg.AddText(message);
SetMessage(msg.Build());
SetMessage(msg);
}
protected override Vector2 MeasureOverride(Vector2 availableSize)
@@ -31,7 +32,8 @@ namespace Robust.Client.UserInterface.Controls
return Vector2.Zero;
}
_entry.Update(_getFontLibrary(), availableSize.X * UIScale, UIScale, defFont: _getFont());
var font = _getFont();
_entry.Update(font, availableSize.X * UIScale, UIScale);
return (_entry.Width / UIScale, _entry.Height / UIScale);
}
@@ -45,42 +47,18 @@ namespace Robust.Client.UserInterface.Controls
return;
}
_entry.Draw(handle, _getFontLibrary(), SizeBox, 0, UIScale, _getFontColor(), defFont: _getFont());
_entry.Draw(handle, _getFont(), SizeBox, 0, new Stack<FormattedMessage.Tag>(), UIScale);
}
[Pure]
private IFontLibrary _getFontLibrary()
private Font _getFont()
{
if (TryGetStyleProperty<IFontLibrary>("font-library", out var flib))
if (TryGetStyleProperty<Font>("font", out var font))
{
return flib;
return font;
}
return UserInterfaceManager
.ThemeDefaults
.DefaultFontLibrary;
}
[Pure]
private FontClass _getFont()
{
if (TryGetStyleProperty<FontClass>("font", out var fclass))
{
return fclass;
}
return _getFontLibrary().Default;
}
[Pure]
private Color _getFontColor()
{
if (TryGetStyleProperty<Color>("font-color", out var fc))
return fc;
// From Robust.Client/UserInterface/RichTextEntry.cs#L19
// at 33008a2bce0cc4755b18b12edfaf5b6f1f87fdd9
return new Color(200, 200, 200);
return UserInterfaceManager.ThemeDefaults.DefaultFont;
}
}
}

View File

@@ -385,10 +385,9 @@ namespace Robust.Client.UserInterface.Controls
[System.Diagnostics.Contracts.Pure]
private Font _getFont()
{
TryGetStyleProperty<FontClass>("font", out var font);
if (TryGetStyleProperty<IFontLibrary>("font-library", out var flib))
if (TryGetStyleProperty<Font>("font", out var font))
{
return flib.StartFont(font).Current;
return font;
}
return UserInterfaceManager.ThemeDefaults.DefaultFont;

View File

@@ -284,10 +284,9 @@ namespace Robust.Client.UserInterface.Controls
private Font? _getFont()
{
TryGetStyleProperty<FontClass>("font", out var font);
if (TryGetStyleProperty<IFontLibrary>("font-library", out var flib))
if (TryGetStyleProperty<Font>("font", out var font))
{
return flib.StartFont(font).Current;
return font;
}
return null;

View File

@@ -6,6 +6,7 @@ using System.Text.Json;
using System.Threading.Tasks;
using Robust.Client.AutoGenerated;
using Robust.Client.Console;
using Robust.Client.Graphics;
using Robust.Client.UserInterface.Controls;
using Robust.Client.UserInterface.XAML;
using Robust.Shared.ContentPack;
@@ -131,11 +132,11 @@ namespace Robust.Client.UserInterface.CustomControls
public void AddLine(string text, Color color)
{
var formatted = new FormattedMessage.Builder();
var formatted = new FormattedMessage(3);
formatted.PushColor(color);
formatted.AddText(text);
formatted.Pop();
AddFormattedLine(formatted.Build());
AddFormattedLine(formatted);
}
public void AddLine(string text)

View File

@@ -1,6 +1,10 @@
using System.Collections.Immutable;
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;
@@ -11,6 +15,9 @@ namespace Robust.Client.UserInterface
/// </summary>
internal struct RichTextEntry
{
private static readonly FormattedMessage.TagColor TagBaseColor
= new(new Color(200, 200, 200));
public readonly FormattedMessage Message;
/// <summary>
@@ -23,93 +30,249 @@ namespace Robust.Client.UserInterface
/// </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>();
}
// Last maxSizeX, used to detect resizing.
private int _lmsx = 0;
// Last UI scale
private float _lUiScale = 0f;
// Layout data, which needs to be refreshed when resized.
private ImmutableArray<TextLayout.Offset>? _ld = default;
/// <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(IFontLibrary font, float maxSizeX, float uiScale, FontClass? defFont = default)
public void Update(Font font, float maxSizeX, float uiScale)
{
var flib = font.StartFont(defFont);
if ((int) maxSizeX != _lmsx || uiScale != _lUiScale || _ld is null)
// 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)
{
_ld = TextLayout.Layout(Message, (int) maxSizeX, font, scale: uiScale, fclass: defFont);
Height = 0;
Width = 0;
foreach (var w in _ld)
// 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))
{
if (w.x + w.w > Width) Width = w.x + w.w;
if (w.y + w.h > Height) Height = w.y;
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;
}
Height -= flib.Current.GetLineSeparation(uiScale) * 2;
_lmsx = (int) maxSizeX;
_lUiScale = uiScale;
}
// 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,
IFontLibrary font,
Font font,
UIBox2 drawBox,
float verticalOffset,
float uiScale,
Color defColor,
FontClass? defFont = default)
// 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)
{
if (_ld is null)
return;
// The tag currently doing color.
var currentColorTag = TagBaseColor;
var flib = font.StartFont(defFont);
foreach (var wd in _ld)
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)
{
var s = Message.Sections[wd.section];
var baseLine = drawBox.TopLeft
+ new Vector2(
(float) wd.x,
verticalOffset
+ (float) wd.y
- (float) flib.Current.GetDescent(uiScale)
- (float) flib.Current.GetLineSeparation(uiScale) * 2
);
foreach (var rune in s
.Content[wd.charOffs..(wd.charOffs+wd.length)]
.EnumerateRunes())
switch (tag)
{
// TODO: Skip drawing when out of the drawBox
baseLine.X += flib.Current.DrawChar(
handle,
rune,
baseLine,
uiScale,
s.Color == default ? defColor :
new Color { // Why Color.FromArgb isn't a thing is beyond me.
A=(float) ((s.Color & 0xFF_00_00_00) >> 24) / 255f,
R=(float) ((s.Color & 0x00_FF_00_00) >> 16) / 255f,
G=(float) ((s.Color & 0x00_00_FF_00) >> 8) / 255f,
B=(float) (s.Color & 0x00_00_00_FF) / 255f
}
);
}
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('-');
}
}
}

View File

@@ -1,427 +0,0 @@
using System;
using System.Linq;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Text;
using Robust.Client.Graphics;
using Robust.Shared.Utility;
namespace Robust.Client.UserInterface
{
public static class TextLayout
{
/// <summary>
/// An Offset is a simplified instruction for rendering a text block.
/// </summary>
///
/// <remarks>
/// Pseudocode for rendering:
/// <code>
/// (int x, int y) topLeft = (10, 20);
/// var libn = style.FontLib.StartFont(defaultFontID, defaultFontStyle, defaultFontSize);
/// foreach (var r in returnedWords)
/// {
/// var section = Message.Sections[section];
/// var font = libn.Update(section.Style, section.Size);
/// font.DrawAt(
/// text=section.Content.Substring(charOffs, length),
/// x=topLeft.x + r.x,
/// y=topLeft.y + r.y,
/// color=new Color(section.Color)
/// )
/// }
/// </code>
/// </remarks>
///
/// <param name="section">
/// The index of the backing store (usually a <see cref="Robust.Shared.Utility.Section"/>) in the
/// container (usually a <see cref="Robust.Shared.Utility.FormattedMessage"/>) to which the Offset belongs.
/// </param>
/// <param name="charOffs">The byte offset in to the <see cref="Robust.Shared.Utility.Section.Content"/> to render.</param>
/// <param name="length">The number of bytes after <paramref name="charOffs"/> to render.</param>
/// <param name="x">The offset from the base position's x coordinate to render this chunk of text.</param>
/// <param name="y">The offset from the base position's y coordinate to render this chunk of text.</param>
/// <param name="w">The width the word (i.e. the sum of all its <c>Advance</c>'s).</param>
/// <param name="h">The height of the tallest character's <c>BearingY</c>.</param>
/// <param name="spw">The width allocated to this word.</param>
/// <param name="wt">The detected word type.</param>
public record struct Offset
{
public int section;
public int charOffs;
public int length;
public int x;
public int y;
public int h;
public int w;
public int spw;
public WordType wt;
}
public enum WordType : byte
{
Normal,
Space,
LineBreak,
}
public static ImmutableArray<Offset> Layout(
ISectionable text,
int w,
IFontLibrary fonts,
float scale = 1.0f,
int lineSpacing = 0,
int wordSpacing = 0,
int runeSpacing = 0,
FontClass? fclass = default,
LayoutOptions options = LayoutOptions.Default
) => Layout(
text,
Split(text, fonts, scale, wordSpacing, runeSpacing, fclass, options),
w,
fonts,
scale,
lineSpacing, wordSpacing,
fclass,
options
);
// Actually produce the layout data.
// The algorithm is basically ripped from CSS Flexbox.
//
// 1. Add up all the space each word takes
// 2. Subtract that from the line width (w)
// 3. Save that as the free space (fs)
// 4. Add up each gap's priority value (Σpri)
// 5. Assign each gap a final priority (fp) of ((priMax - pri) / Σpri)
// 6. That space has (fp*fs) pixels.
public static ImmutableArray<Offset> Layout(
ISectionable src,
ImmutableArray<Offset> text,
int w,
IFontLibrary fonts,
float scale = 1.0f,
int lineSpacing = 0,
int wordSpacing = 0,
FontClass? fclass = default,
LayoutOptions options = LayoutOptions.Default
)
{
var lw = new WorkQueue<(
List<Offset> wds,
List<int> gaps,
int lnrem,
int sptot,
int maxPri,
int tPri,
int lnh
)>(postcreate: i => i with
{
wds = new List<Offset>(),
gaps = new List<int>()
});
var lastAlign = TextAlign.Left;
// Calculate line boundaries
foreach (var wd in text)
{
var hz = src[wd.section].Alignment.Horizontal();
(int gW, int adv) = TransitionWeights(lastAlign, hz);
lastAlign = hz;
lw.Work.gaps.Add(gW+lw.Work.maxPri);
lw.Work.tPri += gW+lw.Work.maxPri;
lw.Work.maxPri += adv;
lw.Work.lnh = Math.Max(lw.Work.lnh, wd.h);
if (lw.Work.lnrem < wd.w || wd.wt == WordType.LineBreak)
{
lw.Flush();
lw.Work.lnrem = w;
lw.Work.maxPri = 1;
}
lw.Work.sptot += wd.spw;
lw.Work.lnrem -= wd.w + wd.spw;
lw.Work.wds.Add(wd);
}
lw.Flush(true);
var flib = fonts.StartFont(fclass);
int py = flib.Current.GetAscent(scale);
foreach ((var ln, var gaps, var lnrem, var sptot, var maxPri, var tPri, var lnh) in lw.Done)
{
int px=0, maxlh=0;
var spDist = new int[gaps.Count];
for (int i = 0; i < gaps.Count; i++)
spDist[i] = (int) (((float) gaps[i] / (float) tPri) * (float) sptot);
int prevAsc=0, prevDesc=0;
for (int i = 0; i < ln.Count; i++)
{
var ss = src[ln[i].section];
var sf = flib.Update(ss.Style, ss.Size);
var asc = sf.GetAscent(scale);
var desc = sf.GetDescent(scale);
maxlh = Math.Max(maxlh, sf.GetAscent(scale));
if (i - 1 > 0 && i - 1 < spDist.Length)
{
px += spDist[i - 1] / 2;
}
ln[i] = ln[i] with {
x = px,
y = py + ss.Alignment.Vertical() switch {
TextAlign.Baseline => 0,
TextAlign.Bottom => -(desc - prevDesc), // Scoot it up by the descent
TextAlign.Top => (asc - prevAsc),
TextAlign.Subscript => -ln[i].h / 8, // Technically these should be derived from the font data,
TextAlign.Superscript => ln[i].h / 4, // but I'm not gonna bother figuring out how to pull it from them.
_ => 0,
}
};
if (i < spDist.Length)
{
px += spDist[i] / 2 + ln[i].w;
}
prevAsc = asc;
prevDesc = desc;
}
py += options.HasFlag(LayoutOptions.UseRenderTop) ? lnh : (lineSpacing + maxlh);
}
return lw.Done.SelectMany(e => e.wds).ToImmutableArray();
}
private static (int gapPri, int adv) TransitionWeights (TextAlign l, TextAlign r)
{
l = l.Horizontal();
r = r.Horizontal();
// Technically these could be slimmed down, but it's as much to help explain the system
// as it is to implement it.
// p (aka gapPri) is how high up the food chain each gap should be.
// _LOWER_ p means more (since we do first-come first-serve).
// a (aka adv) is how much we increment the gapPri counter, meaning how much less important
// future alignment changes are.
// Left alignment.
(int p, int a) la = (l, r) switch {
( TextAlign.Left, TextAlign.Left) => (0, 0), // Left alignment doesn't care about inter-word spacing
( _, TextAlign.Left) => (0, 0), // or anything that comes before it,
( TextAlign.Left, _) => (1, 1), // only what comes after it.
( _, _) => (0, 0)
};
// Right alignment
(int p, int a) ra = (l, r) switch {
( TextAlign.Right, TextAlign.Right) => (0, 0), // Right alignment also does not care about inter-word spacing,
( _, TextAlign.Right) => (1, 1), // but it does care what comes before it,
( TextAlign.Right, _) => (0, 0), // but not after.
( _, _) => (0, 0)
};
// Centering
(int p, int a) ca = (l, r) switch {
( TextAlign.Center, TextAlign.Center) => (0, 0), // Centering still doesn't care about inter-word spacing,
( _, TextAlign.Center) => (1, 0), // but it cares about both what comes before it,
( TextAlign.Center, _) => (1, 1), // and what comes after it.
( _, _) => (0, 0)
};
// Justifying
(int p, int a) ja = (l, r) switch {
(TextAlign.Justify, TextAlign.Justify) => (1, 0), // Justification cares about inter-word spacing.
( _, TextAlign.Justify) => (0, 1), // And (sort of) what comes before it.
( _, _) => (0, 0)
};
return new
(
la.p + ra.p + ca.p + ja.p,
la.a + ra.a + ca.a + ja.a
);
}
// Split creates a list of words broken based on their boundaries.
// Users are encouraged to reuse this for as long as it accurately reflects
// the content they're trying to display.
public static ImmutableArray<Offset> Split(
ISectionable text,
IFontLibrary fonts,
float scale,
int wordSpacing,
int runeSpacing,
FontClass? fclass,
LayoutOptions options = LayoutOptions.Default
)
{
var nofb = options.HasFlag(LayoutOptions.NoFallback);
var s=0;
var lsbo=0;
var sbo=0;
var wq = new WorkQueue<Offset>(
w =>
{
var len = sbo-lsbo;
lsbo = sbo;
return w with { length=len };
},
default,
default,
w => w with { section=s, charOffs=sbo }
);
var flib = fonts.StartFont(fclass);
for (s = 0; s < text.Length; s++)
{
var sec = text[s];
#warning Meta.Localized not yet implemented
if (sec.Meta != default)
throw new Exception("Text section with unknown or unimplemented Meta flag");
lsbo = 0;
sbo = 0;
var fnt = flib.Update(sec.Style, sec.Size);
wq.Reset();
foreach (var r in sec.Content.EnumerateRunes())
{
if (r == (Rune) '\n')
{
wq.Flush();
wq.Work.wt = WordType.LineBreak;
}
else if (Rune.IsSeparator(r))
{
if (wq.Work.wt != WordType.Space)
{
wq.Work.w += wordSpacing;
wq.Flush();
wq.Work.wt = WordType.Space;
}
}
else if (wq.Work.wt != WordType.Normal)
wq.Flush();
sbo += r.Utf16SequenceLength;
var cm = fnt.GetCharMetrics(r, scale, !nofb);
if (!cm.HasValue)
{
if (nofb)
continue;
else if (fnt is DummyFont)
cm = new CharMetrics();
else
throw new Exception("unable to get character metrics");
}
wq.Work.h = Math.Max(wq.Work.h, cm.Value.Height);
wq.Work.w += cm.Value.Advance;
if (wq.Work.wt == WordType.Normal)
wq.Work.spw = runeSpacing;
}
wq.Flush(true);
}
return wq.Done.ToImmutableArray();
}
[Flags]
public enum LayoutOptions : byte
{
Default = 0b0000_0000,
// Measure the actual height of runes to space lines.
UseRenderTop = 0b0000_0001,
// NoFallback disables the use of the Fallback character.
NoFallback = 0b0000_0010,
}
// WorkQueue is probably a misnomer. All it does is streamline a pattern I ended up using
// repeatedly where I'd have a list of something and a WIP, then I'd flush the WIP in to
// the list.
private class WorkQueue<TIn, TOut>
where TIn : new()
{
// _blank creates a new T if _refresh says it needs to.
private Func<TIn> _blank = () => new TIn();
private Func<TIn, TIn>? _postcr;
private Func<TIn, bool> _check = _ => true;
private Func<TIn, TOut> _conv;
public List<TOut> Done = new();
public TIn Work;
public WorkQueue(
Func<TIn, TOut> conv,
Func<TIn>? blank = default,
Func<TIn, bool>? check = default,
Func<TIn, TIn>? postcreate = default
)
{
_conv = conv;
if (blank is not null)
_blank = blank;
if (check is not null)
_check = check;
if (postcreate is not null)
_postcr = postcreate;
Work = _blank.Invoke();
if (_postcr is not null)
Work = _postcr.Invoke(Work);
}
public void Reset()
{
Work = _blank.Invoke();
if (_postcr is not null)
Work = _postcr.Invoke(Work);
}
public void Flush(bool force = false)
{
if (_check.Invoke(Work) || force)
{
Done.Add(_conv(Work));
Work = _blank.Invoke();
if (_postcr is not null)
Work = _postcr.Invoke(Work);
}
}
}
private class WorkQueue<T> : WorkQueue<T, T>
where T : new()
{
private static Func<T, T> __conv = i => i;
public WorkQueue(
Func<T, T>? conv = default,
Func<T>? blank = default,
Func<T, bool>? check = default,
Func<T, T>? postcreate = default
) : base(conv ?? __conv, blank, check, postcreate)
{
}
}
}
}

View File

@@ -10,39 +10,19 @@ namespace Robust.Client.UserInterface
/// </summary>
public abstract class UITheme
{
public abstract IFontLibrary DefaultFontLibrary { get; }
public abstract IFontLibrary LabelFontLibrary { get; }
public Font DefaultFont { get => DefaultFontLibrary.StartFont().Current; }
public Font LabelFont { get => LabelFontLibrary.StartFont().Current; }
public abstract Font DefaultFont { get; }
public abstract Font LabelFont { get; }
public abstract StyleBox PanelPanel { get; }
public abstract StyleBox ButtonStyle { get; }
public abstract StyleBox LineEditBox { get; }
}
public sealed class UIThemeDummy : UITheme
{
private static readonly FontClass _defaultFontClass = new FontClass ( Id: "dummy", Size: default, Style: default );
public override IFontLibrary DefaultFontLibrary { get; } = new FontLibrary(_defaultFontClass);
public override IFontLibrary LabelFontLibrary { get; } = new FontLibrary(_defaultFontClass);
public override Font DefaultFont { get; } = new DummyFont();
public override Font LabelFont { get; } = new DummyFont();
public override StyleBox PanelPanel { get; } = new StyleBoxFlat();
public override StyleBox ButtonStyle { get; } = new StyleBoxFlat();
public override StyleBox LineEditBox { get; } = new StyleBoxFlat();
public UIThemeDummy() : base()
{
DefaultFontLibrary.AddFont("dummy",
new []
{
new DummyVariant (default)
}
);
LabelFontLibrary.AddFont("dummy",
new []
{
new DummyVariant (default)
}
);
}
}
}

View File

@@ -184,12 +184,12 @@ namespace Robust.Server.Scripting
newScript.Compile();
// Echo entered script.
var echoMessage = new FormattedMessage.Builder();
var echoMessage = new FormattedMessage();
ScriptInstanceShared.AddWithSyntaxHighlighting(newScript, echoMessage, code, instance.HighlightWorkspace);
replyMessage.Echo = echoMessage.Build();
replyMessage.Echo = echoMessage;
var msg = new FormattedMessage.Builder();
var msg = new FormattedMessage();
try
{
@@ -215,7 +215,7 @@ namespace Robust.Server.Scripting
PromptAutoImports(e.Diagnostics, code, msg, instance);
replyMessage.Response = msg.Build();
replyMessage.Response = msg;
_netManager.ServerSendMessage(replyMessage, message.MsgChannel);
return;
}
@@ -240,7 +240,7 @@ namespace Robust.Server.Scripting
msg.AddText(ScriptInstanceShared.SafeFormat(instance.State.ReturnValue));
}
replyMessage.Response = msg.Build();
replyMessage.Response = msg;
_netManager.ServerSendMessage(replyMessage, message.MsgChannel);
}
@@ -316,7 +316,7 @@ namespace Robust.Server.Scripting
private void PromptAutoImports(
IEnumerable<Diagnostic> diags,
string code,
FormattedMessage.Builder output,
FormattedMessage output,
ScriptInstance instance)
{
if (!ScriptInstanceShared.CalcAutoImports(_reflectionManager, diags, out var found))

View File

@@ -77,7 +77,7 @@ namespace Robust.Shared.Scripting
"var x = 5 + 5; var y = (object) \"foobar\"; void Foo(object a) { } Foo(y); Foo(x)";
var script = await CSharpScript.RunAsync(code);
var msg = new FormattedMessage.Builder();
var msg = new FormattedMessage();
// Even run the syntax highlighter!
AddWithSyntaxHighlighting(script.Script, msg, code, new AdhocWorkspace());
});
@@ -101,7 +101,7 @@ namespace Robust.Shared.Scripting
return _getDiagnosticArguments(diag);
}
public static void AddWithSyntaxHighlighting(Script script, FormattedMessage.Builder msg, string code,
public static void AddWithSyntaxHighlighting(Script script, FormattedMessage msg, string code,
Workspace workspace)
{
var compilation = script.GetCompilation();

View File

@@ -8,7 +8,6 @@ using Robust.Shared.Serialization.Markdown.Validation;
using Robust.Shared.Serialization.Markdown.Value;
using Robust.Shared.Serialization.TypeSerializers.Interfaces;
using Robust.Shared.Utility;
using Robust.Shared.Utility.Markup;
namespace Robust.Shared.Serialization.TypeSerializers.Implementations
{
@@ -19,16 +18,14 @@ namespace Robust.Shared.Serialization.TypeSerializers.Implementations
ValueDataNode node, IDependencyCollection dependencies, bool skipHook,
ISerializationContext? context = null)
{
var bParser = new Basic();
bParser.AddMarkup(node.Value);
return new DeserializedValue<FormattedMessage>(bParser.Render());
return new DeserializedValue<FormattedMessage>(FormattedMessage.FromMarkup(node.Value));
}
public ValidationNode Validate(ISerializationManager serializationManager, ValueDataNode node,
IDependencyCollection dependencies,
ISerializationContext? context = null)
{
return Basic.ValidMarkup(node.Value)
return FormattedMessage.ValidMarkup(node.Value)
? new ValidatedValueNode(node)
: new ErrorNode(node, "Invalid markup in FormattedMessage.");
}
@@ -44,8 +41,7 @@ namespace Robust.Shared.Serialization.TypeSerializers.Implementations
public FormattedMessage Copy(ISerializationManager serializationManager, FormattedMessage source,
FormattedMessage target, bool skipHook, ISerializationContext? context = null)
{
// based value types
return source;
return new(source);
}
}
}

View File

@@ -6,7 +6,7 @@ using JetBrains.Annotations;
namespace Robust.Shared.Utility
{
public static partial class Extensions
public static class Extensions
{
public static IList<T> Clone<T>(this IList<T> listToClone) where T : ICloneable
{

View File

@@ -0,0 +1,106 @@
using System.Collections.Generic;
using Pidgin;
using Robust.Shared.Maths;
using static Pidgin.Parser;
using static Pidgin.Parser<char>;
namespace Robust.Shared.Utility
{
public partial class FormattedMessage
{
// wtf I love parser combinators now.
private const char TagBegin = '[';
private const char TagEnd = ']';
private static readonly Parser<char, char> ParseEscapeSequence =
Char('\\').Then(OneOf(
Char('\\'),
Char(TagBegin),
Char(TagEnd)));
private static readonly Parser<char, TagText> ParseTagText =
ParseEscapeSequence.Or(Token(c => c != TagBegin && c != '\\'))
.AtLeastOnceString()
.Select(s => new TagText(s));
private static readonly Parser<char, TagColor> ParseTagColor =
String("color")
.Then(Char('='))
.Then(Token(ValidColorNameContents).AtLeastOnceString()
.Select(s =>
{
if (Color.TryFromName(s, out var color))
{
return new TagColor(color);
}
return new TagColor(Color.FromHex(s));
}));
private static readonly Parser<char, TagPop> ParseTagPop =
Char('/')
.Then(String("color"))
.ThenReturn(TagPop.Instance);
private static readonly Parser<char, Tag> ParseTagContents =
ParseTagColor.Cast<Tag>().Or(ParseTagPop.Cast<Tag>());
private static readonly Parser<char, Tag> ParseEnclosedTag =
ParseTagContents.Between(Char(TagBegin), Char(TagEnd));
private static readonly Parser<char, Tag> ParseTagOrFallBack =
Try(ParseEnclosedTag)
// If we couldn't parse a tag then parse the [ of the start of the tag
// so the rest is recognized as text.
.Or(Char(TagBegin).ThenReturn<Tag>(new TagText("[")));
private static readonly Parser<char, IEnumerable<Tag>> Parse =
ParseTagText.Cast<Tag>().Or(ParseEnclosedTag).Many();
private static readonly Parser<char, IEnumerable<Tag>> ParsePermissive =
ParseTagText.Cast<Tag>().Or(ParseTagOrFallBack).Many();
public static bool ValidMarkup(string markup)
{
return Parse.Parse(markup).Success;
}
public void AddMarkup(string markup)
{
_tags.AddRange(Parse.ParseOrThrow(markup));
}
/// <summary>
/// Will parse invalid markup tags as text instead of ignoring them.
/// </summary>
public void AddMarkupPermissive(string markup)
{
_tags.AddRange(ParsePermissive.ParseOrThrow(markup));
}
private static bool ValidColorNameContents(char c)
{
// Match contents of valid color name.
if (c == '#')
{
return true;
}
if (c >= 'a' && c <= 'z')
{
return true;
}
if (c >= 'A' && c <= 'Z')
{
return true;
}
if (c >= '0' && c <= '9')
{
return true;
}
return false;
}
}
}

View File

@@ -1,5 +1,5 @@
using System;
using System.Linq;
using System.Collections;
using System.Collections.Generic;
using System.Text;
using JetBrains.Annotations;
@@ -8,283 +8,186 @@ using Robust.Shared.Serialization;
namespace Robust.Shared.Utility
{
public interface ISectionable
{
Section this[int i] { get; }
int Length { get; }
}
[Serializable, NetSerializable]
public record struct Section
{
public FontStyle Style = default;
public FontSize Size = default;
public TextAlign Alignment = default;
public int Color = default;
public MetaFlags Meta = default;
public string Content = string.Empty;
}
[Flags]
public enum MetaFlags : byte
{
Default = 0,
Localized = 1,
// All other values are reserved.
}
[Flags]
public enum FontStyle : byte
{
// Single-font styles
Normal = 0b0000_0000,
Bold = 0b0000_0001,
Italic = 0b0000_0010,
Monospace = 0b0000_0100,
BoldItalic = Bold | Italic,
// Escape value
Special = 0b1000_0000,
// The lower four bits are available for styles to specify.
Standard = 0b0100_0000 | Special,
// All values not otherwise specified are reserved.
}
[Flags]
public enum FontSize : ushort
{
// Format (Standard): 0bSNNN_NNNN_NNNN_NNNN
// S: Special flag.
// N (where S == 0): Font size. Unsigned.
// N (where S == 1): Special operation; see below.
// Flag to indicate the TagFontSize is "special".
// All values not specified are reserved.
// General Format: 0b1PPP_AAAA_AAAA_AAAA
// P: Operation.
// A: Arguments.
Special = 0b1000_0000_0000_0000,
// RELative Plus.
// Format: 0b1100_NNNN_NNNN_NNNN
// N: Addend to the previous font size. Unsigned.
RelPlus = 0b0100_0000_0000_0000 | Special,
// RELative Minus.
// Format: 0b1010_NNNN_NNNN_NNNN
// N: Subtrahend to the previous font size. Unsigned.
RelMinus = 0b0010_0000_0000_0000 | Special,
// Selects a font size from the stylesheet.
// Format: 0b1110_NNNN_NNNN_NNNN
// N: The identifier of the preset font size.
Standard = 0b0110_0000_0000_0000 | Special,
}
public enum TextAlign : byte
{
// Format: 0bHHHH_VVVV
// H: Horizontal alignment
// V: Vertical alignment.
// All values not specified are reserved.
// This seems dumb to point out, but ok
Default = Baseline | Left,
// Vertical alignment
Baseline = 0x00,
Top = 0x01,
Bottom = 0x02,
Superscript = 0x03,
Subscript = 0x04,
// Horizontal alignment
Left = 0x00,
Right = 0x10,
Center = 0x20,
Justify = 0x30,
}
public static partial class Extensions
{
public static TextAlign Vertical (this TextAlign value) => (TextAlign)((byte) value & 0x0F);
public static TextAlign Horizontal (this TextAlign value) => (TextAlign)((byte) value & 0xF0);
}
/// <summary>
/// Represents a formatted message in the form of a list of "tags".
/// Does not do any concrete formatting, simply useful as an API surface.
/// </summary>
[PublicAPI]
[Serializable, NetSerializable]
public sealed record FormattedMessage(Section[] Sections) : ISectionable
public sealed partial class FormattedMessage
{
public TagList Tags => new(_tags);
private readonly List<Tag> _tags;
public FormattedMessage()
{
_tags = new List<Tag>();
}
public FormattedMessage(int capacity)
{
_tags = new List<Tag>(capacity);
}
public static FormattedMessage FromMarkup(string markup)
{
var msg = new FormattedMessage();
msg.AddMarkup(markup);
return msg;
}
public static FormattedMessage FromMarkupPermissive(string markup)
{
var msg = new FormattedMessage();
msg.AddMarkupPermissive(markup);
return msg;
}
/// <summary>
/// Escape a string of text to be able to be formatted into markup.
/// </summary>
public static string EscapeText(string text)
{
return text.Replace("\\", "\\\\").Replace("[", "\\[");
}
/// <summary>
/// Remove all markup, leaving only the basic text content behind.
/// </summary>
public static string RemoveMarkup(string text)
{
return FromMarkup(text).ToString();
}
/// <summary>
/// Create a new <c>FormattedMessage</c> by copying another one.
/// </summary>
/// <param name="toCopy">The message to copy.</param>
public FormattedMessage(FormattedMessage toCopy)
{
_tags = toCopy._tags.ShallowClone();
}
public void AddText(string text)
{
_tags.Add(new TagText(text));
}
public void PushColor(Color color)
{
_tags.Add(new TagColor(color));
}
public void PushNewline()
{
AddText("\n");
}
public void Pop()
{
_tags.Add(new TagPop());
}
public void AddMessage(FormattedMessage other)
{
_tags.AddRange(other.Tags);
}
public void Clear()
{
_tags.Clear();
}
/// <returns>The string without markup tags.</returns>
public override string ToString()
{
var sb = new StringBuilder();
foreach (var i in Sections)
sb.Append(i.Content);
var builder = new StringBuilder();
foreach (var tag in _tags)
{
if (tag is not TagText text)
{
continue;
}
return sb.ToString();
builder.Append(text.Text);
}
return builder.ToString();
}
// I don't wanna fix the serializer yet.
/// <returns>The string without filtering out markup tags.</returns>
public string ToMarkup()
{
#warning FormattedMessage.ToMarkup is still lossy.
var sb = new StringBuilder();
foreach (var i in Sections)
var builder = new StringBuilder();
foreach (var tag in _tags)
{
if (i.Content.Length == 0)
continue;
if (i.Color != default)
sb.AppendFormat("[color=#{0:X8}]",
// Bit twiddling to swap AARRGGBB to RRGGBBAA
((i.Color << 8) & 0xFF_FF_FF_00) | // Drop alpha from the front
((i.Color & 0xFF_00_00_00) >> 24) // Shuffle it to the back
);
sb.Append(i.Content);
if (i.Color != default)
sb.Append("[/color]");
builder.Append(tag);
}
return sb.ToString();
return builder.ToString();
}
public static readonly FormattedMessage Empty = new FormattedMessage(Array.Empty<Section>());
public Section this[int i] { get => Sections[i]; }
public int Length { get => Sections.Length; }
// are you a construction worker?
// cuz you buildin
[Obsolete("Construct FormattedMessage Sections manually.")]
public class Builder
[Serializable, NetSerializable]
public abstract record Tag
{
// _dirty signals that _sb has content that needs flushing to _work
private bool _dirty = false;
}
// We fake a stack by keeping an index in to the work list.
// Since each Section contains all its styling info, we can "pop" the stack by
// using the (unchanged) Section before it.
private int _idx = 0;
private StringBuilder _sb = new();
// _work starts out with a dummy item because otherwise we break the assumption that
// _idx will always refer to *something* in _work.
private List<Section> _work = new() {
new Section()
};
public static Builder FromFormattedMessage(FormattedMessage orig) => new ()
[Serializable, NetSerializable]
public sealed record TagText(string Text) : Tag
{
public override string ToString()
{
// Again, we always need at least one _work item, so if the FormattedMessage
// is empty, we'll forge one.
_idx = orig.Sections.Length < 0 ? orig.Sections.Length - 1 : 0,
_work = new List<Section>(
orig.Sections.Length == 0 ?
new [] { new Section() }
: orig.Sections
),
};
return Text;
}
}
// hmm what could this do
public void Clear()
[Serializable, NetSerializable]
public sealed record TagColor(Color Color) : Tag
{
public override string ToString()
{
_dirty = false;
_idx = 0;
_work = new() {
new Section()
};
_sb = _sb.Clear();
return $"[color={Color.ToHex()}]";
}
}
[Serializable, NetSerializable]
public sealed record TagPop : Tag
{
public static readonly TagPop Instance = new();
public override string ToString()
{
return $"[/color]";
}
}
public readonly struct TagList : IReadOnlyList<Tag>
{
private readonly List<Tag> _tags;
public TagList(List<Tag> tags)
{
_tags = tags;
}
// Since we don't change any styling, we don't need to add a full Section.
// In these cases, we add it to the StringBuilder, and wait until styling IS changed,
// or we Render().
public void AddText(string text)
public List<Tag>.Enumerator GetEnumerator()
{
_dirty = true;
_sb.Append(text);
return _tags.GetEnumerator();
}
// PushColor changes the styling, so we need to submit any text we had waiting, then
// add a new empty Section with the new color.
public void PushColor(Color color)
IEnumerator<Tag> IEnumerable<Tag>.GetEnumerator()
{
flushWork();
var last = _work[_idx];
last.Content = string.Empty;
last.Color = color.ToArgb();
_work.Add(last);
_idx = _work.Count - 1;
return _tags.GetEnumerator();
}
// These next two are probably wildly bugged, since they'll include the other sections
// wholesale, and the entire fake-stack facade breaks down, since there's no way for the
// new stuff to inherit the previous style, and we don't know what parts of the style are
// actually set, and what parts are just default values.
// TODO: move _idx?
public void AddMessage(FormattedMessage other)
IEnumerator IEnumerable.GetEnumerator()
{
flushWork();
_work.AddRange(other.Sections);
_idx = _work.Count-1;
return _tags.GetEnumerator();
}
// TODO: See above
public void AddMessage(FormattedMessage.Builder other)
{
flushWork();
AddMessage(other.Build());
other.Clear();
}
public int Count => _tags.Count;
// I wish I understood why this was needed...
// Did people not know you could AddText("\n")?
public void PushNewline()
{
_dirty = true;
_sb.Append('\n');
}
// Flush any text we've got for the current style,
// then roll back to the style before this one.
public void Pop()
{
flushWork();
// Go back one (or stay at the start)
_idx = (_idx > 0) ? (_idx - 1) : 0;
}
public void flushWork()
{
// Nothing changed? Great.
if (!_dirty)
return;
// Get the last tag (for the style)...
var last = _work[_idx];
// ...and set the content to the current buffer
last.Content = _sb.ToString();
_work.Add(last);
// Clean up
_sb = _sb.Clear();
_dirty = false;
}
public FormattedMessage Build()
{
flushWork();
return new FormattedMessage(_work
.GetRange(1, _work.Count - 1) // Drop the placeholder
.Where(e => e.Content.Length != 0) // and any blanks (which can happen from pushing colors and such)
.ToArray());
}
public Tag this[int index] => _tags[index];
}
}
}

View File

@@ -1,167 +0,0 @@
using System;
using System.Collections.Generic;
using Pidgin;
using Robust.Shared.Maths;
using static Pidgin.Parser;
using static Pidgin.Parser<char>;
namespace Robust.Shared.Utility.Markup
{
public class Basic
{
internal record tag;
internal record tagText(string text) : tag;
internal record tagColor(Color color) : tag;
internal record tagPop() : tag;
private List<tag> _tags = new();
// wtf I love parser combinators now.
private const char TagBegin = '[';
private const char TagEnd = ']';
private static readonly Parser<char, char> ParseEscapeSequence =
Char('\\').Then(OneOf(
Char('\\'),
Char(TagBegin),
Char(TagEnd)));
private static readonly Parser<char, tagText> ParseTagText =
ParseEscapeSequence.Or(Token(c => c != TagBegin && c != '\\'))
.AtLeastOnceString()
.Select(s => new tagText(s));
private static readonly Parser<char, tagColor> ParseTagColor =
String("color")
.Then(Char('='))
.Then(Token(ValidColorNameContents).AtLeastOnceString()
.Select(s =>
{
if (Color.TryFromName(s, out var color))
return new tagColor(color);
return new tagColor(Color.FromHex(s));
}));
private static readonly Parser<char, tagPop> ParseTagPop =
Char('/')
.Then(String("color"))
.ThenReturn(new tagPop());
private static readonly Parser<char, tag> ParseTagContents =
ParseTagColor.Cast<tag>().Or(ParseTagPop.Cast<tag>());
private static readonly Parser<char, tag> ParseEnclosedTag =
ParseTagContents.Between(Char(TagBegin), Char(TagEnd));
private static readonly Parser<char, tag> ParseTagOrFallBack =
Try(ParseEnclosedTag)
// If we couldn't parse a tag then parse the [ of the start of the tag
// so the rest is recognized as text.
.Or(Char(TagBegin).ThenReturn<tag>(new tagText("[")));
private static readonly Parser<char, IEnumerable<tag>> Parse =
ParseTagText.Cast<tag>().Or(ParseEnclosedTag).Many();
private static readonly Parser<char, IEnumerable<tag>> ParsePermissive =
ParseTagText.Cast<tag>().Or(ParseTagOrFallBack).Many();
public static bool ValidMarkup(string markup)
{
return Parse.Parse(markup).Success;
}
public void AddMarkup(string markup)
{
_tags.AddRange(Parse.ParseOrThrow(markup));
}
/// <summary>
/// Will parse invalid markup tags as text instead of ignoring them.
/// </summary>
public void AddMarkupPermissive(string markup)
{
_tags.AddRange(ParsePermissive.ParseOrThrow(markup));
}
private static bool ValidColorNameContents(char c)
{
// Match contents of valid color name.
if (c == '#')
{
return true;
}
if (c >= 'a' && c <= 'z')
{
return true;
}
if (c >= 'A' && c <= 'Z')
{
return true;
}
if (c >= '0' && c <= '9')
{
return true;
}
return false;
}
public FormattedMessage Render(Section? defStyle = default) => Build(defStyle).Build();
public FormattedMessage.Builder Build(Section? defStyle = default)
{
FormattedMessage.Builder b;
if (defStyle != null)
b = FormattedMessage.Builder.FromFormattedMessage(
new FormattedMessage(new[] {defStyle.Value})
);
else
b = new FormattedMessage.Builder();
foreach (var t in _tags)
{
switch (t)
{
case tagText txt: b.AddText(txt.text); break;
case tagColor col: b.PushColor(col.color); break;
case tagPop: b.Pop(); break;
}
}
return b;
}
public static FormattedMessage.Builder BuildMarkup(string text, Section? defStyle = default)
{
var nb = new Basic();
nb.AddMarkup(text);
return nb.Build(defStyle);
}
public static FormattedMessage RenderMarkup(string text, Section? defStyle = default) => BuildMarkup(text, defStyle).Build();
/// <summary>
/// Escape a string of text to be able to be formatted into markup.
/// </summary>
public static string EscapeText(string text)
{
return text.Replace("\\", "\\\\").Replace("[", "\\[");
}
}
public static class FormattedMessageExtensions
{
public static void AddMarkup(this FormattedMessage.Builder bld, string text) => bld.AddMessage(Basic.BuildMarkup(text));
[Obsolete("Use Basic.EscapeText instead.")]
public static void EscapeText(this FormattedMessage _, string text) => Basic.EscapeText(text);
[Obsolete("Use Basic.EscapeText instead.")]
public static void EscapeText(this FormattedMessage.Builder _, string text) => Basic.EscapeText(text);
}
}

View File

@@ -3,7 +3,6 @@ using Robust.Shared.Serialization.Manager;
using Robust.Shared.Serialization.Markdown.Value;
using Robust.Shared.Serialization.TypeSerializers.Implementations;
using Robust.Shared.Utility;
using Robust.Shared.Utility.Markup;
// ReSharper disable AccessToStaticMemberViaDerivedType
@@ -18,9 +17,8 @@ public class FormattedMessageSerializerTest : SerializationTest
[TestCase("[color=#FF0000FF]message[/color]")]
public void SerializationTest(string text)
{
var message = new Basic();
message.AddMarkup(text);
var node = Serialization.WriteValueAs<ValueDataNode>(message.Render());
var message = FormattedMessage.FromMarkup(text);
var node = Serialization.WriteValueAs<ValueDataNode>(message);
Assert.That(node.Value, Is.EqualTo(text));
}

View File

@@ -1,40 +1,42 @@
using System.Linq;
using NUnit.Framework;
using Robust.Shared.Maths;
using Robust.Shared.Utility;
using Robust.Shared.Utility.Markup;
namespace Robust.UnitTesting.Shared.Utility
{
[Parallelizable(ParallelScope.All)]
[TestFixture]
[TestOf(typeof(Basic))]
public class MarkupBasic_Test
[TestOf(typeof(FormattedMessage))]
public class FormattedMessage_Test
{
[Test]
public static void TestParseMarkup()
{
var msg = new Basic();
msg.AddMarkup("foo[color=#aabbcc]bar[/color]baz");
var msg = FormattedMessage.FromMarkup("foo[color=#aabbcc]bar[/color]baz");
Assert.That(msg.Render().Sections, NUnit.Framework.Is.EquivalentTo(new[]
Assert.That(msg.Tags, NUnit.Framework.Is.EquivalentTo(new FormattedMessage.Tag[]
{
new Section { Content="foo" },
new Section { Content="bar", Color=unchecked ((int) 0xFFAABBCC) },
new Section { Content="baz" }
new FormattedMessage.TagText("foo"),
new FormattedMessage.TagColor(Color.FromHex("#aabbcc")),
new FormattedMessage.TagText("bar"),
FormattedMessage.TagPop.Instance,
new FormattedMessage.TagText("baz")
}));
}
[Test]
public static void TestParseMarkupColorName()
{
var msg = new Basic();
msg.AddMarkup("foo[color=orange]bar[/color]baz");
var msg = FormattedMessage.FromMarkup("foo[color=orange]bar[/color]baz");
Assert.That(msg.Render().Sections, NUnit.Framework.Is.EquivalentTo(new[]
Assert.That(msg.Tags, NUnit.Framework.Is.EquivalentTo(new FormattedMessage.Tag[]
{
new Section { Content="foo" },
new Section { Content="bar", Color=Color.Orange.ToArgb() },
new Section { Content="baz" }
new FormattedMessage.TagText("foo"),
new FormattedMessage.TagColor(Color.Orange),
new FormattedMessage.TagText("bar"),
FormattedMessage.TagPop.Instance,
new FormattedMessage.TagText("baz")
}));
}
@@ -44,23 +46,30 @@ namespace Robust.UnitTesting.Shared.Utility
[TestCase("foo[stinky] bar")]
public static void TestParsePermissiveMarkup(string text)
{
var msg = new Basic();
msg.AddMarkupPermissive(text);
var msg = FormattedMessage.FromMarkupPermissive(text);
Assert.That(
msg.Render().ToString(),
string.Join("", msg.Tags.Cast<FormattedMessage.TagText>().Select(p => p.Text)),
NUnit.Framework.Is.EqualTo(text));
}
[Test]
[TestCase("Foo", ExpectedResult = "Foo")]
[TestCase("[color=red]Foo[/color]", ExpectedResult = "Foo")]
[TestCase("[color=red]Foo[/color]bar", ExpectedResult = "Foobar")]
public string TestRemoveMarkup(string test)
{
return FormattedMessage.RemoveMarkup(test);
}
[Test]
[TestCase("Foo")]
[TestCase("[color=#FF000000]Foo[/color]")]
[TestCase("[color=#00FF00FF]Foo[/color]bar")]
public static void TestToMarkup(string text)
{
var message = new Basic();
message.AddMarkup(text);
Assert.That(message.Render().ToMarkup(), NUnit.Framework.Is.EqualTo(text));
var message = FormattedMessage.FromMarkup(text);
Assert.That(message.ToMarkup(), NUnit.Framework.Is.EqualTo(text));
}
}
}