Emoji support

* Add support for raw emoji parsing in TextWidget

* Fix SDL not honouring Wayland Support setting

* Fix improper markup parsing in TextWidget after emoji support was added

* Use Twemoji for emoji, and add support for COLR font glyphs.

* Add support for emoji rendering in TextWidget if the font is provided

* Add support for parsing emoji shortcodes in TextWidget

* Add emoji lookup table to AcidicGUI

* Add Noto Color Emoji font for future use
This commit is contained in:
Ritchie Frodomar 2024-11-27 02:35:56 +00:00
parent f22f252aac
commit d2565530ed
16 changed files with 3959 additions and 108 deletions

View file

@ -3,13 +3,13 @@
"isRoot": true, "isRoot": true,
"tools": { "tools": {
"dotnet-sdcb": { "dotnet-sdcb": {
"version": "1.0.0.456", "version": "1.0.0.568",
"commands": [ "commands": [
"sdcb" "sdcb"
] ]
}, },
"dotnet-sdfxc": { "dotnet-sdfxc": {
"version": "1.0.0.456", "version": "1.0.0.568",
"commands": [ "commands": [
"sdfxc" "sdfxc"
] ]

View file

@ -9,7 +9,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="FreeTypeSharp" Version="3.0.0" /> <PackageReference Include="FreeTypeSharp" Version="3.0.0" />
<PackageReference Include="MonoGame.Framework.DesktopGL" Version="1.0.0.456" /> <PackageReference Include="MonoGame.Framework.DesktopGL" Version="1.0.0.568" />
</ItemGroup> </ItemGroup>
</Project> </Project>

File diff suppressed because it is too large Load diff

View file

@ -1,22 +1,34 @@
using System.Buffers.Binary;
using System.Drawing;
using System.Drawing.Imaging;
using System.Reflection.Metadata; using System.Reflection.Metadata;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Text;
using AcidicGUI.Rendering; using AcidicGUI.Rendering;
using FreeTypeSharp; using FreeTypeSharp;
using Microsoft.Xna.Framework; using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics; using Microsoft.Xna.Framework.Graphics;
using Color = Microsoft.Xna.Framework.Color;
using Point = Microsoft.Xna.Framework.Point;
using Rectangle = Microsoft.Xna.Framework.Rectangle;
namespace AcidicGUI.TextRendering; namespace AcidicGUI.TextRendering;
public class Typeface : Font public class Typeface : Font
{ {
private static readonly FreeTypeLibrary library = new(); private static readonly FreeTypeLibrary library = new();
private static readonly Rune carriageReturn = new Rune('\r');
private static readonly Rune lineFeed = new Rune('\n');
private readonly GraphicsDevice graphicsDevice; private readonly GraphicsDevice graphicsDevice;
private readonly Atlas referenceAtlas; private readonly Atlas referenceAtlas;
private static bool hasInitialized; private static bool hasInitialized;
private readonly int baseFontSize; private readonly int baseFontSize;
private readonly Memory<byte> fontData; private readonly Memory<byte> fontData;
private readonly Dictionary<GraphicsDevice, AtlasContainer> atlases = new(); private readonly Dictionary<GraphicsDevice, AtlasContainer> atlases = new();
private readonly Dictionary<uint, Range> svgGlyphRanges = new();
private bool hasReadSvgTable = false;
private unsafe Typeface(byte[] data, int referenceFontSize, GraphicsDevice graphicsDevice) private unsafe Typeface(byte[] data, int referenceFontSize, GraphicsDevice graphicsDevice)
{ {
this.graphicsDevice = graphicsDevice; this.graphicsDevice = graphicsDevice;
@ -27,11 +39,89 @@ public class Typeface : Font
referenceAtlas = GetAtlas(graphicsDevice, referenceFontSize); referenceAtlas = GetAtlas(graphicsDevice, referenceFontSize);
} }
private string? GetSvgDocument(uint glyph)
{
var fontSpan = fontData.Span;
Span<byte> identifierSpan = stackalloc byte[4];
identifierSpan[0] = (byte) 'S';
identifierSpan[1] = (byte) 'V';
identifierSpan[2] = (byte)'G';
identifierSpan[3] = (byte)' ';
if (!hasReadSvgTable)
{
hasReadSvgTable = true;
int svgOffset = 0;
int svgLength = 0;
var fontHeader = fontSpan.Slice(0, 12);
var numTablesRaw = fontHeader.Slice(4, 2);
var numTables = BinaryPrimitives.ReadUInt16BigEndian(numTablesRaw);
for (var i = 0; i < numTables; i++)
{
const int size = 16;
var offset = 12 + size * i;
var descriptor = fontSpan.Slice(offset, size);
var tag = descriptor.Slice(0, identifierSpan.Length);
if (!identifierSpan.SequenceEqual(tag))
continue;
svgOffset = BinaryPrimitives.ReadInt32BigEndian(descriptor.Slice(8, 4));
svgLength = BinaryPrimitives.ReadInt32BigEndian(descriptor.Slice(12, 4));
}
if (svgOffset == 0 || svgLength == 0)
return null;
var svgSpan = fontSpan.Slice((int)svgOffset, (int)svgLength);
var offsetToSvgDocIndex = BinaryPrimitives.ReadInt32BigEndian(svgSpan.Slice(2, 4));
var docIndex = svgSpan.Slice(offsetToSvgDocIndex);
var numEntries = BinaryPrimitives.ReadUInt16BigEndian(docIndex.Slice(0, 2));
var entriesSpan = docIndex.Slice(2);
for (var i = 0; i < numEntries; i++)
{
const int size = 12;
var entry = entriesSpan.Slice(size * i, size);
var startingGlyph = BinaryPrimitives.ReadUInt16BigEndian(entry.Slice(0, 2));
var endingGlyph = BinaryPrimitives.ReadUInt16BigEndian(entry.Slice(2, 2));
var offset = BinaryPrimitives.ReadInt32BigEndian(entry.Slice(4, 4));
var length = BinaryPrimitives.ReadInt32BigEndian(entry.Slice(8, 4));
var range = new Range((int)(svgOffset + offsetToSvgDocIndex + offset), (int)(svgOffset + offsetToSvgDocIndex + offset + length));
for (var glyphIndex = startingGlyph; glyphIndex <= endingGlyph; glyphIndex++)
{
svgGlyphRanges.Add(glyphIndex, range);
}
}
}
if (!svgGlyphRanges.TryGetValue(glyph, out var svgRange))
return null;
var (stringOffset, stringLength) = svgRange.GetOffsetAndLength(fontSpan.Length);
var svgData = fontSpan.Slice(stringOffset, stringLength);
return Encoding.UTF8.GetString(svgData);
}
public override float ReferenceFontSize => referenceAtlas.FontSize; public override float ReferenceFontSize => referenceAtlas.FontSize;
public override float UnderlinePosition => referenceAtlas.UnderlinePosition; public override float UnderlinePosition => referenceAtlas.UnderlinePosition;
public override unsafe Point Measure(string text, int? fontSize = null) public override unsafe Point Measure(string text, int? fontSize = null)
{ {
var runes = text.EnumerateRunes().ToArray();
var atlas = GetAtlas(graphicsDevice, fontSize ?? referenceAtlas.FontSize); var atlas = GetAtlas(graphicsDevice, fontSize ?? referenceAtlas.FontSize);
float lineHeight = atlas.LineHeight; float lineHeight = atlas.LineHeight;
@ -40,27 +130,25 @@ public class Typeface : Font
float width = 0; float width = 0;
float height = 0; float height = 0;
var chars = text.AsSpan(); atlas.RasterizeGlyphs(runes);
atlas.RasterizeGlyphs(chars);
var lastWasNewline = false; var lastWasNewline = false;
var info = default(GlyphInfo); var info = default(GlyphInfo);
for (var i = 0; i < chars.Length; i++) for (var i = 0; i < runes.Length; i++)
{ {
char character = chars[i]; Rune character = runes[i];
// Newline characters // Newline characters
if (character == '\r' || character == '\n') if (character == carriageReturn || character == lineFeed)
{ {
// Check the next char after this one. If it's also a newline, but isn't the same kind as // Check the next char after this one. If it's also a newline, but isn't the same kind as
// the current character, we treat both newlines as if they're one. // the current character, we treat both newlines as if they're one.
// //
// This will gracefully handle "\r\n" and "\n\r". // This will gracefully handle "\r\n" and "\n\r".
if (i + 1 < chars.Length) if (i + 1 < runes.Length)
{ {
char nextChar = chars[i + 1]; var nextChar = runes[i + 1];
if (nextChar != character && nextChar == '\r' || nextChar == '\n') if (nextChar != character && nextChar == carriageReturn || nextChar == lineFeed)
i++; i++;
} }
@ -95,8 +183,14 @@ public class Typeface : Font
int? fontSize = null int? fontSize = null
) )
{ {
if (string.IsNullOrWhiteSpace(text))
return;
if (geometryHelper.GraphicsDevice != this.graphicsDevice) if (geometryHelper.GraphicsDevice != this.graphicsDevice)
throw new InvalidOperationException("You may only use this typeface to render text to the same graphics device that the typeface was created with."); throw new InvalidOperationException("You may only use this typeface to render text to the same graphics device that the typeface was created with.");
var runes = text.EnumerateRunes().ToArray();
float sizeScale = (fontSize ?? referenceAtlas.FontSize) / (float) referenceAtlas.FontSize; float sizeScale = (fontSize ?? referenceAtlas.FontSize) / (float) referenceAtlas.FontSize;
float baselineOffset = referenceAtlas.Ascender * sizeScale; float baselineOffset = referenceAtlas.Ascender * sizeScale;
@ -112,28 +206,28 @@ public class Typeface : Font
var chars = text.AsSpan(); var chars = text.AsSpan();
atlas.RasterizeGlyphs(chars); atlas.RasterizeGlyphs(runes);
var glyphInfo = default(GlyphInfo); var glyphInfo = default(GlyphInfo);
var lastTexture = null as Texture2D; var lastTexture = null as Texture2D;
var lastMesh = null as GuiMeshBuilder; var lastMesh = null as GuiMeshBuilder;
for (var i = 0; i < chars.Length; i++) for (var i = 0; i < runes.Length; i++)
{ {
char character = chars[i]; var character = runes[i];
// Newline characters // Newline characters
if (character == '\r' || character == '\n') if (character == carriageReturn || character == lineFeed)
{ {
// Check the next char after this one. If it's also a newline, but isn't the same kind as // Check the next char after this one. If it's also a newline, but isn't the same kind as
// the current character, we treat both newlines as if they're one. // the current character, we treat both newlines as if they're one.
// //
// This will gracefully handle "\r\n" and "\n\r". // This will gracefully handle "\r\n" and "\n\r".
if (i + 1 < chars.Length) if (i + 1 < runes.Length)
{ {
char nextChar = chars[i + 1]; var nextChar = runes[i + 1];
if (nextChar != character && nextChar == '\r' || nextChar == '\n') if (nextChar != character && nextChar == carriageReturn || nextChar == lineFeed)
i++; i++;
} }
@ -238,11 +332,12 @@ public class Typeface : Font
private sealed unsafe class Atlas private sealed unsafe class Atlas
{ {
private const int defaultAtlasSize = 1024; private const int defaultAtlasSize = 1024;
private readonly Typeface typeface;
private readonly int fontSize; private readonly int fontSize;
private readonly GraphicsDevice device; private readonly GraphicsDevice device;
private readonly List<GlyphInfo> glyphInfo = new(); private readonly List<GlyphInfo> glyphInfo = new();
private readonly List<Texture2D> atlasTextures = new(); private readonly List<Texture2D> atlasTextures = new();
private readonly Dictionary<char, int> glyphMap = new(); private readonly Dictionary<Rune, int> glyphMap = new();
private readonly FT_FaceRec_* face; private readonly FT_FaceRec_* face;
private readonly float ascender; private readonly float ascender;
private readonly float descender; private readonly float descender;
@ -259,8 +354,12 @@ public class Typeface : Font
public float UnderlinePosition => underlinePosition; public float UnderlinePosition => underlinePosition;
public float LineHeight => lineHeight; public float LineHeight => lineHeight;
public Atlas(GraphicsDevice device, int fontSize, FT_FaceRec_* face) public Atlas(GraphicsDevice device, Typeface typeface, int fontSize, FT_FaceRec_* face)
{ {
this.typeface = typeface;
FT.FT_Select_Charmap(face, FT_Encoding_.FT_ENCODING_UNICODE);
this.device = device; this.device = device;
this.fontSize = fontSize; this.fontSize = fontSize;
this.face = face; this.face = face;
@ -281,43 +380,60 @@ public class Typeface : Font
underlinePosition = ascender + (fontSize * 0.1f); underlinePosition = ascender + (fontSize * 0.1f);
} }
var flags = default(FT_LOAD);
if (fontSize <= 12) if (fontSize <= 12)
{ {
// Disable anti-aliasing for low font sizes to make them less blurry. // Disable anti-aliasing for low font sizes to make them less blurry.
loadFlags = FT_LOAD.FT_LOAD_MONOCHROME | FT_LOAD.FT_LOAD_FORCE_AUTOHINT; flags = FT_LOAD.FT_LOAD_MONOCHROME | FT_LOAD.FT_LOAD_FORCE_AUTOHINT;
} }
else else
{ {
loadFlags = FT_LOAD.FT_LOAD_FORCE_AUTOHINT; flags = FT_LOAD.FT_LOAD_FORCE_AUTOHINT;
} }
if ((face->face_flags & (uint)FT_FACE_FLAG.FT_FACE_FLAG_COLOR) != 0)
flags |= FT_LOAD.FT_LOAD_COLOR;
loadFlags = flags;
} }
public void GetGlyphInfo(char character, ref GlyphInfo info) public void GetGlyphInfo(Rune character, ref GlyphInfo info)
{ {
var index = glyphMap[character]; var index = glyphMap[character];
info = glyphInfo[index]; info = glyphInfo[index];
} }
public void RasterizeGlyphs( ReadOnlySpan<char> characters) public void RasterizeGlyphs( ReadOnlySpan<Rune> characters)
{ {
for (var i = 0; i < characters.Length; i++) for (var i = 0; i < characters.Length; i++)
{ {
char character = characters[i]; Rune character = characters[i];
if (glyphMap.TryGetValue(character, out int glyphIndex)) if (glyphMap.TryGetValue(character, out int glyphIndex))
continue; continue;
glyphIndex = glyphInfo.Count; glyphIndex = glyphInfo.Count;
FT.FT_Load_Char(face, (nuint)character, FT_LOAD.FT_LOAD_RENDER | loadFlags); FT.FT_Load_Char(face, (nuint) character.Value, FT_LOAD.FT_LOAD_RENDER | loadFlags);
uint rasterWidth = face->glyph->bitmap.width; uint rasterWidth = face->glyph->bitmap.width;
uint rasterHeight = face->glyph->bitmap.rows; uint rasterHeight = face->glyph->bitmap.rows;
var isSvg = (face->face_flags & (uint)FT_FACE_FLAG.FT_FACE_FLAG_SVG) != 0;
var isVisual = !(rasterWidth == 0 || rasterHeight == 0 || char.IsWhiteSpace(character)); var isVisual = isSvg || !(rasterWidth == 0 || rasterHeight == 0 || Rune.IsWhiteSpace(character));
Texture2D? texture = null; Texture2D? texture = null;
byte[]? svgBitmapData = null;
if (isSvg)
{
var glyphIndexInFont = face->glyph->glyph_index;
var svgString = typeface.GetSvgDocument(glyphIndexInFont);
throw new InvalidOperationException("Rendering SVG-based fonts is not supported yet. If you feel like tackling the immense performance problems involved in supporting it, then we're accepting merge requests. :)");
}
if (isVisual) if (isVisual)
{ {
if (atlasTextures.Count > 0) if (atlasTextures.Count > 0)
@ -350,18 +466,37 @@ public class Typeface : Font
} }
} }
CopyGlyph( if (isSvg)
texture, {
face, CopyGlyph(
rasterWidth, texture,
rasterHeight, face,
cursorX, rasterWidth,
cursorY, rasterHeight,
ref cursorLineHeight, cursorX,
out GlyphInfo info cursorY,
); ref cursorLineHeight,
out GlyphInfo info,
svgBitmapData
);
this.glyphInfo.Add(info); this.glyphInfo.Add(info);
}
else
{
CopyGlyph(
texture,
face,
rasterWidth,
rasterHeight,
cursorX,
cursorY,
ref cursorLineHeight,
out GlyphInfo info
);
this.glyphInfo.Add(info);
}
cursorX += (int) rasterWidth; cursorX += (int) rasterWidth;
glyphMap.Add(character, glyphIndex); glyphMap.Add(character, glyphIndex);
@ -376,65 +511,94 @@ public class Typeface : Font
int atlasX, int atlasX,
int atlasY, int atlasY,
ref int lineHeight, ref int lineHeight,
out GlyphInfo info out GlyphInfo info,
byte[]? bitmapData = null
) )
{ {
info = default; info = default;
if (atlas != null) if (atlas != null)
{ {
var isMonochrome = loadFlags.HasFlag(FT_LOAD.FT_LOAD_MONOCHROME); var format = face->glyph->bitmap.pixel_mode;
var colors = new Color[(int)rasterWidth * (int)rasterHeight]; var colors = new Color[(int)rasterWidth * (int)rasterHeight];
if (isMonochrome) switch (format)
{ {
// thank you, chatgpt, for generating this cursed shit case FT_Pixel_Mode_.FT_PIXEL_MODE_GRAY:
for (var y = 0; y < rasterHeight; y++)
{ {
for (var x = 0; x < rasterWidth; x++) for (var y = 0; y < rasterHeight; y++)
{ {
// Each byte in the buffer contains 8 pixels (1 bit per pixel) for (var x = 0; x < rasterWidth; x++)
int byteIndex = y * face->glyph->bitmap.pitch + (x / 8);
byte bitMask = (byte)(0x80 >> (x % 8)); // Get the specific bit for this pixel
bool isPixelSet = (face->glyph->bitmap.buffer[byteIndex] & bitMask) != 0;
int colorIndex = y * (int)rasterWidth + x;
if (isPixelSet)
{ {
// Set the pixel to black (opaque) int index = y * face->glyph->bitmap.pitch + x;
byte alpha = face->glyph->bitmap.buffer[index];
int colorIndex = y * (int)rasterWidth + x;
colors[colorIndex].R = 255; colors[colorIndex].R = 255;
colors[colorIndex].G = 255; colors[colorIndex].G = 255;
colors[colorIndex].B = 255; colors[colorIndex].B = 255;
colors[colorIndex].A = 255; colors[colorIndex].A = alpha;
}
else
{
// Set the pixel to transparent (white background)
colors[colorIndex].R = 0;
colors[colorIndex].G = 0;
colors[colorIndex].B = 0;
colors[colorIndex].A = 0;
} }
} }
break;
} }
} case FT_Pixel_Mode_.FT_PIXEL_MODE_MONO:
else
{
for (var y = 0; y < rasterHeight; y++)
{ {
for (var x = 0; x < rasterWidth; x++) // thank you, chatgpt, for generating this cursed shit
for (var y = 0; y < rasterHeight; y++)
{ {
int index = y * face->glyph->bitmap.pitch + x; for (var x = 0; x < rasterWidth; x++)
byte alpha = face->glyph->bitmap.buffer[index]; {
int colorIndex = y * (int)rasterWidth + x; // Each byte in the buffer contains 8 pixels (1 bit per pixel)
int byteIndex = y * face->glyph->bitmap.pitch + (x / 8);
byte bitMask = (byte)(0x80 >> (x % 8)); // Get the specific bit for this pixel
colors[colorIndex].R = 255; bool isPixelSet = (face->glyph->bitmap.buffer[byteIndex] & bitMask) != 0;
colors[colorIndex].G = 255;
colors[colorIndex].B = 255; int colorIndex = y * (int)rasterWidth + x;
colors[colorIndex].A = alpha;
if (isPixelSet)
{
// Set the pixel to black (opaque)
colors[colorIndex].R = 255;
colors[colorIndex].G = 255;
colors[colorIndex].B = 255;
colors[colorIndex].A = 255;
}
else
{
// Set the pixel to transparent (white background)
colors[colorIndex].R = 0;
colors[colorIndex].G = 0;
colors[colorIndex].B = 0;
colors[colorIndex].A = 0;
}
}
} }
break;
}
case FT_Pixel_Mode_.FT_PIXEL_MODE_BGRA:
{
for (int y = 0; y < face->glyph->bitmap.rows; y++)
{
for (int x = 0; x < face->glyph->bitmap.width; x++)
{
int offset = (y * face->glyph->bitmap.pitch) + (x * 4); // 4 bytes per pixel
var colorIndex = y * rasterWidth + x;
colors[colorIndex].B = face->glyph->bitmap.buffer[offset + 0];
colors[colorIndex].G = face->glyph->bitmap.buffer[offset + 1];
colors[colorIndex].R = face->glyph->bitmap.buffer[offset + 2];
colors[colorIndex].A = face->glyph->bitmap.buffer[offset + 3];
}
}
break;
}
default:
{
throw new InvalidOperationException("Unsupported glyph format: " + format.ToString());
} }
} }
@ -474,10 +638,13 @@ public class Typeface : Font
info.VisualOffsetX = face->glyph->bitmap_left; info.VisualOffsetX = face->glyph->bitmap_left;
info.VisualOffsetY = face->glyph->bitmap_top; info.VisualOffsetY = face->glyph->bitmap_top;
if (info.VisualOffsetY == 0)
info.VisualOffsetY = (int) rasterHeight;
lineHeight = Math.Max(lineHeight, (int) rasterHeight); lineHeight = Math.Max(lineHeight, (int) rasterHeight);
} }
} }
private sealed unsafe class AtlasContainer(GraphicsDevice device, Typeface typeface) private sealed unsafe class AtlasContainer(GraphicsDevice device, Typeface typeface)
{ {
private readonly Typeface typeface = typeface; private readonly Typeface typeface = typeface;
@ -488,7 +655,7 @@ public class Typeface : Font
{ {
if (!atlases.TryGetValue(fontSize, out var atlas)) if (!atlases.TryGetValue(fontSize, out var atlas))
{ {
atlas = new Atlas(device, fontSize, typeface.LoadFont()); atlas = new Atlas(device, typeface, fontSize, typeface.LoadFont());
atlases.Add(fontSize, atlas); atlases.Add(fontSize, atlas);
} }
@ -496,6 +663,8 @@ public class Typeface : Font
} }
} }
public struct GlyphInfo public struct GlyphInfo
{ {
public bool IsVisual; public bool IsVisual;

View file

@ -9,6 +9,7 @@ namespace AcidicGUI.VisualStyles;
internal sealed class FallbackVisualStyle : IVisualStyle internal sealed class FallbackVisualStyle : IVisualStyle
{ {
public Font? IconFont => null; public Font? IconFont => null;
public Font? EmojiFont => null;
public Padding DropdownButtonPadding { get; } = 3; public Padding DropdownButtonPadding { get; } = 3;
public Color SelectionColor => Color.LightBlue; public Color SelectionColor => Color.LightBlue;
public Color TextSelectionBackground => Color.Blue; public Color TextSelectionBackground => Color.Blue;

View file

@ -29,6 +29,7 @@ public interface IVisualStyle :
IVisualRenderer<Popover.PopoverVisualState> IVisualRenderer<Popover.PopoverVisualState>
{ {
Font? IconFont { get; } Font? IconFont { get; }
Font? EmojiFont { get; }
Padding DropdownButtonPadding { get; } Padding DropdownButtonPadding { get; }
Color SelectionColor { get; } Color SelectionColor { get; }
Color TextSelectionBackground { get; } Color TextSelectionBackground { get; }

View file

@ -46,6 +46,12 @@ public static class StyleManager
activeStyle = newStyle; activeStyle = newStyle;
} }
internal static Font? GetEmojiFont(Widget widget)
{
var style = widget.GetVisualStyleOverride() ?? activeStyle;
return style?.EmojiFont;
}
internal static IFontFamily GetFont(PresetFontFamily family, IVisualStyle? styleOverride) internal static IFontFamily GetFont(PresetFontFamily family, IVisualStyle? styleOverride)
{ {
var style = styleOverride ?? activeStyle; var style = styleOverride ?? activeStyle;

View file

@ -21,7 +21,8 @@ public class TextWidget : Widget,
private static readonly Dictionary<string, Texture2D> images = new(); private static readonly Dictionary<string, Texture2D> images = new();
private static IImageLocator? _imageLocator; private static IImageLocator? _imageLocator;
private bool parseEmoji = true;
private int previousWrapWidth; private int previousWrapWidth;
private Color color = Color.White; private Color color = Color.White;
private FontFamilyInfo fontFamily; private FontFamilyInfo fontFamily;
@ -135,6 +136,20 @@ public class TextWidget : Widget,
InvalidateLayout(); InvalidateLayout();
} }
} }
public bool AllowEmoji
{
get => parseEmoji;
set
{
if (parseEmoji == value)
return;
parseEmoji = value;
RebuildText();
InvalidateLayout();
}
}
public bool UseMarkup public bool UseMarkup
{ {
@ -345,8 +360,28 @@ public class TextWidget : Widget,
geometry.AddQuad(highlightRect, highlight); geometry.AddQuad(highlightRect, highlight);
} }
family.Draw(geometry, element.Position.ToVector2(), renderColor, element.Text, element.MarkupData.FontSize ?? FontSize, if (element.IsEmoji && element.EmojiFont != null)
element.MarkupData.Weight ?? FontWeight, element.MarkupData.Italic); {
element.EmojiFont.Draw(
geometry,
element.Position.ToVector2(),
renderColor,
element.Text,
element.MarkupData.FontSize ?? fontSize
);
}
else
{
family.Draw(
geometry,
element.Position.ToVector2(),
renderColor,
element.Text,
element.MarkupData.FontSize ?? FontSize,
element.MarkupData.Weight ?? FontWeight,
element.MarkupData.Italic
);
}
var underlineOffset = family.GetUnderlineOffset(element.MarkupData.FontSize ?? FontSize, element.MarkupData.Weight ?? FontWeight, element.MarkupData.Italic); var underlineOffset = family.GetUnderlineOffset(element.MarkupData.FontSize ?? FontSize, element.MarkupData.Weight ?? FontWeight, element.MarkupData.Italic);
var strikeLine = 1; var strikeLine = 1;
@ -396,11 +431,27 @@ public class TextWidget : Widget,
{ {
if (!textElements[i].MarkupData.IsImage) if (!textElements[i].MarkupData.IsImage)
{ {
var family = (textElements[i].MarkupData.FontOverride ?? fontFamily).GetFont(this); if (textElements[i].IsEmoji && textElements[i].EmojiFont != null)
var newMeasurement = family.Measure(textElements[i].Text.TrimEnd(), textElements[i].MarkupData.FontSize ?? FontSize, textElements[i].MarkupData.Weight ?? FontWeight, textElements[i].MarkupData.Italic); {
var emojiFont = textElements[i].EmojiFont;
var newMeasurement = emojiFont.Measure(textElements[i].Text.TrimEnd(), textElements[i].MarkupData.FontSize ?? FontSize);
newMeasurement.Y = family.GetLineHeight(textElements[i].MarkupData.FontSize ?? FontSize, textElements[i].MarkupData.Weight ?? FontWeight, textElements[i].MarkupData.Italic); newMeasurement.Y = emojiFont.GetLineHeight(textElements[i].MarkupData.FontSize ?? FontSize);
textElements[i].MeasuredSize = newMeasurement; textElements[i].MeasuredSize = newMeasurement;
}
else
{
var family = (textElements[i].MarkupData.FontOverride ?? fontFamily).GetFont(this);
var newMeasurement = family.Measure(
textElements[i].Text.TrimEnd(),
textElements[i].MarkupData.FontSize ?? FontSize,
textElements[i].MarkupData.Weight ?? FontWeight,
textElements[i].MarkupData.Italic
);
newMeasurement.Y = family.GetLineHeight(textElements[i].MarkupData.FontSize ?? FontSize, textElements[i].MarkupData.Weight ?? FontWeight, textElements[i].MarkupData.Italic);
textElements[i].MeasuredSize = newMeasurement;
}
} }
} }
@ -417,14 +468,33 @@ public class TextWidget : Widget,
{ {
offset.X -= textElements[i - 1].MeasuredSize!.Value.X; offset.X -= textElements[i - 1].MeasuredSize!.Value.X;
var newFamily = (textElements[i - 1].MarkupData.FontOverride ?? fontFamily).GetFont(this); if (textElements[i - 1].IsEmoji && textElements[i - 1].EmojiFont != null)
{
var newEmojiFont = textElements[i - 1].EmojiFont;
var newMeasurement = newFamily.Measure(textElements[i - 1].Text.TrimEnd(), textElements[i - 1].MarkupData.FontSize ?? FontSize, textElements[i - 1].MarkupData.Weight ?? FontWeight, textElements[i - 1].MarkupData.Italic); var newMeasurement = newEmojiFont!.Measure(textElements[i - 1].Text.TrimEnd(), textElements[i - 1].MarkupData.FontSize ?? FontSize);
newMeasurement.Y = newFamily.GetLineHeight(textElements[i - 1].MarkupData.FontSize ?? FontSize, textElements[i - 1].MarkupData.Weight ?? FontWeight, textElements[i - 1].MarkupData.Italic); newMeasurement.Y = newEmojiFont.GetLineHeight(textElements[i - 1].MarkupData.FontSize ?? FontSize);
textElements[i - 1].MeasuredSize = newMeasurement; textElements[i - 1].MeasuredSize = newMeasurement;
offset.X += newMeasurement.X;
}
else
{
var newFamily = (textElements[i - 1].MarkupData.FontOverride ?? fontFamily).GetFont(this);
offset.X += newMeasurement.X; var newMeasurement = newFamily.Measure(
textElements[i - 1].Text.TrimEnd(),
textElements[i - 1].MarkupData.FontSize ?? FontSize,
textElements[i - 1].MarkupData.Weight ?? FontWeight,
textElements[i - 1].MarkupData.Italic
);
newMeasurement.Y = newFamily.GetLineHeight(textElements[i - 1].MarkupData.FontSize ?? FontSize, textElements[i - 1].MarkupData.Weight ?? FontWeight, textElements[i - 1].MarkupData.Italic);
textElements[i - 1].MeasuredSize = newMeasurement;
offset.X += newMeasurement.X;
}
} }
} }
@ -654,11 +724,34 @@ public class TextWidget : Widget,
images.Add(path, image); images.Add(path, image);
return image; return image;
} }
private bool ParseEmoji(ReadOnlySpan<char> characters, int startIndex, out int rawLength, out string? codepoint)
{
codepoint = default;
rawLength = 0;
var afterColon = characters.Slice(startIndex + 1);
var nextColon = afterColon.IndexOf(':');
if (nextColon == -1)
return false;
var identifier = afterColon.Slice(0, nextColon).ToString();
if (!Emojis.All.TryGetValue(identifier, out uint codepointValue))
return false;
codepoint = char.ConvertFromUtf32((int)codepointValue);
rawLength = 2 + identifier.Length;
return true;
}
private void RebuildText() private void RebuildText()
{ {
var markupData = new MarkupData(); var markupData = new MarkupData();
var newMarkupData = new MarkupData(); var newMarkupData = new MarkupData();
var emojiFont = StyleManager.GetEmojiFont(this);
var emojiSupported = parseEmoji && emojiFont != null;
var sourceStart = 0; var sourceStart = 0;
@ -707,9 +800,56 @@ public class TextWidget : Widget,
switch (character.Value) switch (character.Value)
{ {
case '<' when useMarkup: case { } when character.Value >= 0xd800 && character.Value <= 0xdbff:
{ {
if (!ParseMarkup(chars, i, ref newMarkupData)) if (i + 1 >= chars.Length)
continue;
char nextChar = chars[i + 1];
if (nextChar < 0xdc00 || nextChar > 0xdfff)
goto default;
Rune surrogatePair = new Rune(character.Value, nextChar);
if (!emojiSupported)
goto default;
if (!Emojis.Codepoints.Contains((uint)surrogatePair.Value))
goto default;
textElements.Add(new TextElement
{
Text = stringBuilder.ToString(),
SourceStart = sourceStart,
SourceEnd = i,
MarkupData = markupData
});
stringBuilder.Length = 0;
sourceStart = i;
stringBuilder.Append($"{character.Value}{nextChar}");
i += 2;
textElements.Add(new TextElement
{
Text = stringBuilder.ToString(),
IsEmoji = true,
EmojiFont = emojiFont,
SourceStart = sourceStart,
SourceEnd = i,
MarkupData = markupData
});
stringBuilder.Length = 0;
sourceStart = i;
i--;
break;
}
case ':' when emojiSupported && useMarkup:
{
if (!ParseEmoji(chars, i, out int rawLength, out string? codepoint))
goto default; goto default;
textElements.Add(new TextElement textElements.Add(new TextElement
@ -719,6 +859,47 @@ public class TextWidget : Widget,
SourceEnd = i, SourceEnd = i,
MarkupData = markupData MarkupData = markupData
}); });
stringBuilder.Length = 0;
sourceStart = i;
stringBuilder.Append(codepoint);
i += rawLength;
textElements.Add(new TextElement
{
Text = stringBuilder.ToString(),
IsEmoji = true,
EmojiFont = emojiFont,
SourceStart = sourceStart,
SourceEnd = i,
MarkupData = markupData
});
stringBuilder.Length = 0;
sourceStart = i;
i--;
break;
}
case '<' when useMarkup:
{
if (!ParseMarkup(chars, i, ref newMarkupData))
goto default;
if (stringBuilder.Length > 0)
{
textElements.Add(
new TextElement
{
Text = stringBuilder.ToString(),
SourceStart = sourceStart,
SourceEnd = i,
MarkupData = markupData
}
);
stringBuilder.Length = 0;
}
int markupLength = newMarkupData.Length; int markupLength = newMarkupData.Length;
@ -737,9 +918,6 @@ public class TextWidget : Widget,
markupData = newMarkupData; markupData = newMarkupData;
stringBuilder.Length = 0;
sourceStart = i;
i += markupLength - 1; i += markupLength - 1;
break; break;
} }
@ -956,6 +1134,12 @@ public class TextWidget : Widget,
if (textElements[i].MeasuredSize != null) if (textElements[i].MeasuredSize != null)
continue; continue;
if (textElements[i].IsEmoji && textElements[i].EmojiFont != null)
{
textElements[i].MeasuredSize = textElements[i].EmojiFont!.Measure(textElements[i].Text, textElements[i].MarkupData.FontSize ?? fontSize);
continue;
}
if (family == null || lastOverride != textElements[i].MarkupData.FontOverride) if (family == null || lastOverride != textElements[i].MarkupData.FontOverride)
{ {
family = (textElements[i].MarkupData.FontOverride ?? fontFamily).GetFont(this); family = (textElements[i].MarkupData.FontOverride ?? fontFamily).GetFont(this);
@ -1068,6 +1252,8 @@ public class TextWidget : Widget,
private class TextElement private class TextElement
{ {
public string Text = string.Empty; public string Text = string.Empty;
public bool IsEmoji;
public Font? EmojiFont;
public Point Position; public Point Position;
public Point? MeasuredSize; public Point? MeasuredSize;
public bool IsNewLine; public bool IsNewLine;

View file

@ -1,7 +1,9 @@
using System.Collections.Immutable; using System.Collections.Immutable;
using System.Net.Mime;
using Microsoft.DotNet.PlatformAbstractions; using Microsoft.DotNet.PlatformAbstractions;
using Microsoft.Xna.Framework.Graphics; using Microsoft.Xna.Framework.Graphics;
using SociallyDistant.Core.Core.Config; using SociallyDistant.Core.Core.Config;
using SociallyDistant.Core.Modules;
namespace SociallyDistant.Core.Config.SystemConfigCategories namespace SociallyDistant.Core.Config.SystemConfigCategories
{ {
@ -144,8 +146,14 @@ namespace SociallyDistant.Core.Config.SystemConfigCategories
"Wayland Support", "Wayland Support",
"Change whether Socially Distant uses Wayland or X11 to communicate with your desktop environment. Disabling Wayland support may work around bugs on certain desktops. Changing this setting requires a game restart.", "Change whether Socially Distant uses Wayland or X11 to communicate with your desktop environment. Disabling Wayland support may work around bugs on certain desktops. Changing this setting requires a game restart.",
UseWaylandBackend, UseWaylandBackend,
x => UseWaylandBackend = x, x =>
linux {
UseWaylandBackend = x;
Application.Instance.Restart();
},
linux,
true,
"Changing this setting requires the game to restart. Are you sure you want to continue? Any unsaved progress will be lost."
); );
} }
} }

View file

@ -61,7 +61,9 @@ public class SociallyDistantVisualStyle : IVisualStyle,
private readonly ChatMessageStyle chatMessageStyle; private readonly ChatMessageStyle chatMessageStyle;
private readonly Color mainBackground = new Color(0x11, 0x13, 0x15); private readonly Color mainBackground = new Color(0x11, 0x13, 0x15);
private readonly CompletionListSTyle completionListSTyle; private readonly CompletionListSTyle completionListSTyle;
private readonly PopoverStyle popoverStyle; private readonly PopoverStyle popoverStyle;
private Font? emojiFont;
private readonly Color statusBarColor = new Color( private readonly Color statusBarColor = new Color(
0x01, 0x01,
@ -125,6 +127,7 @@ public class SociallyDistantVisualStyle : IVisualStyle,
private Texture2D? checkboxEmblem; private Texture2D? checkboxEmblem;
public Font? IconFont => iconFont; public Font? IconFont => iconFont;
public Font? EmojiFont => emojiFont;
public Padding DropdownButtonPadding { get; } = new Padding( public Padding DropdownButtonPadding { get; } = new Padding(
1, 1,
@ -173,6 +176,7 @@ public class SociallyDistantVisualStyle : IVisualStyle,
checkboxEmblem = game.GameInstance.Content.Load<Texture2D>("/Core/UI/Textures/checkbox_emblem"); checkboxEmblem = game.GameInstance.Content.Load<Texture2D>("/Core/UI/Textures/checkbox_emblem");
iconFont = Typeface.FromStream(graphicsDevice, game.GameInstance.Content.Load<Stream>("/Core/UI/lucide.ttf"), 16); iconFont = Typeface.FromStream(graphicsDevice, game.GameInstance.Content.Load<Stream>("/Core/UI/lucide.ttf"), 16);
emojiFont = Typeface.FromStream(graphicsDevice, game.GameInstance.Content.Load<Stream>("/Core/UI/Fonts/Emoji/Twemoji.Mozilla.ttf"), 16);
defaultFont = LoadFont("/Core/UI/Fonts/Rajdhani", graphicsDevice); defaultFont = LoadFont("/Core/UI/Fonts/Rajdhani", graphicsDevice);
monospace = LoadFont("/Core/UI/Fonts/Monospace/JetBrainsMono", graphicsDevice); monospace = LoadFont("/Core/UI/Fonts/Monospace/JetBrainsMono", graphicsDevice);

View file

@ -8,9 +8,11 @@ metadata() {
} }
email() { email() {
echo Hello world. echo ":earth_americas:" Hello world.
echo This is a test. echo This is a test.
echo If you are reading this, "<b>this text should be bold.</b>" echo "💖 this is a test of raw unicode emoji parsing, if you saw the heart then it worked."
echo If you are :book: reading this, "<b>this text should be bold.</b>"
echo :eyes: Emoji are supported as well.
} }
start() { start() {

View file

@ -0,0 +1,93 @@
Copyright 2021 Google Inc. All Rights Reserved.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View file

@ -1,4 +1,6 @@
using System.Diagnostics; using System.Diagnostics;
using System.Runtime.InteropServices;
using Microsoft.Xna.Framework;
using Serilog; using Serilog;
using SociallyDistant.Core.Config; using SociallyDistant.Core.Config;
using SociallyDistant.Core.Config.SystemConfigCategories; using SociallyDistant.Core.Config.SystemConfigCategories;
@ -23,15 +25,20 @@ internal sealed class GameApplication : Application
settingsManager.Load(); settingsManager.Load();
Environment.SetEnvironmentVariable("SDL_LOG_PRIORITY", "debug"); Environment.SetEnvironmentVariable("SDL_LOG_PRIORITY", "debug");
var waylandDisplayIsPresent = !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("WAYLAND_DISPLAY")); var isLinux = RuntimeInformation.IsOSPlatform(OSPlatform.Linux);
var waylandDisplayIsPresent = isLinux && !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("WAYLAND_DISPLAY"));
var graphicsSettings = new GraphicsSettings(settingsManager); var graphicsSettings = new GraphicsSettings(settingsManager);
if (waylandDisplayIsPresent && graphicsSettings.UseWaylandBackend) if (waylandDisplayIsPresent && graphicsSettings.UseWaylandBackend)
{ {
Log.Information("Running SDL2 in Wayland mode."); Log.Information("Running SDL2 in Wayland mode.");
Environment.SetEnvironmentVariable("SDL_VIDEODRIVER", "wayland"); SdlPlatformSettings.PreferredVideoDriver = "wayland";
}
else if (isLinux)
{
SdlPlatformSettings.PreferredVideoDriver = "xcb"; // fallback to X11.
} }
game = new SociallyDistantGame(this); game = new SociallyDistantGame(this);

View file

@ -23,7 +23,7 @@
<PackageReference Include="AutoPipeline" Version="0.0.2" /> <PackageReference Include="AutoPipeline" Version="0.0.2" />
<PackageReference Include="FuzzySharp" Version="2.0.2" /> <PackageReference Include="FuzzySharp" Version="2.0.2" />
<PackageReference Include="JetBrains.Annotations" Version="2024.2.0" /> <PackageReference Include="JetBrains.Annotations" Version="2024.2.0" />
<PackageReference Include="MonoGame.Content.Builder.Task" Version="1.0.0.456" /> <PackageReference Include="MonoGame.Content.Builder.Task" Version="1.0.0.568" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@ -99,6 +99,12 @@
<None Update="Content\UI\lucide.ttf"> <None Update="Content\UI\lucide.ttf">
<CopyToOutputDirectory>Always</CopyToOutputDirectory> <CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None> </None>
<None Update="Content\UI\Fonts\Emoji\OFL.txt">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Content\UI\Fonts\Emoji\Twemoji.Mozilla.ttf">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View file

@ -47,7 +47,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="ImGui.NET" Version="1.90.1.1" /> <PackageReference Include="ImGui.NET" Version="1.90.1.1" />
<PackageReference Include="MonoGame.Framework.DesktopGL" Version="1.0.0.456" /> <PackageReference Include="MonoGame.Framework.DesktopGL" Version="1.0.0.568" />
</ItemGroup> </ItemGroup>
<Target Name="RestoreDotnetTools" BeforeTargets="Restore"> <Target Name="RestoreDotnetTools" BeforeTargets="Restore">