mirror of
https://github.com/space-wizards/RobustToolbox.git
synced 2026-02-14 19:29:36 +01:00
Revert Rich Text (#2363)
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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(">");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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('-');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
106
Robust.Shared/Utility/FormattedMessage.MarkupParser.cs
Normal file
106
Robust.Shared/Utility/FormattedMessage.MarkupParser.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user