SeString renderer: Implement replacement entity (#1993)

* Refactor

* Implement replacement entity

* Apply rounding functions more correctly
This commit is contained in:
srkizer 2024-08-04 20:30:49 +09:00 committed by GitHub
parent 23a2bd6228
commit 878b96e67d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 1199 additions and 672 deletions

View file

@ -2,6 +2,7 @@ using System.Buffers.Binary;
using System.Diagnostics.CodeAnalysis;
using System.Drawing;
using System.Numerics;
using System.Runtime.CompilerServices;
namespace Dalamud.Interface;
@ -247,6 +248,14 @@ public static class ColorHelpers
public static uint Desaturate(uint color, float amount)
=> RgbaVector4ToUint(Desaturate(RgbaUintToVector4(color), amount));
/// <summary>Applies the given opacity value ranging from 0 to 1 to an uint value containing a RGBA value.</summary>
/// <param name="rgba">RGBA value to transform.</param>
/// <param name="opacity">Opacity to apply.</param>
/// <returns>Transformed value.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static uint ApplyOpacity(uint rgba, float opacity) =>
((uint)MathF.Round((rgba >> 24) * opacity) << 24) | (rgba & 0xFFFFFFu);
/// <summary>
/// Fade a color.
/// </summary>

View file

@ -145,5 +145,16 @@ internal sealed unsafe class GfdFile : FileResource
/// <summary>Gets the UV1 of the HQ version of this entry.</summary>
public Vector2 HqUv1 => new((this.Left + this.Width) / 256f, (this.Top + this.Height + 170.5f) / 512f);
/// <summary>Calculates the size in pixels of a GFD entry when drawn along with a text.</summary>
/// <param name="fontSize">Font size in pixels.</param>
/// <param name="useHq">Whether to draw the HQ texture.</param>
/// <returns>Determined size of the GFD entry when drawn.</returns>
public readonly Vector2 CalculateScaledSize(float fontSize, out bool useHq)
{
useHq = fontSize > 19;
var targetHeight = useHq ? fontSize : 20;
return new(this.Width * (targetHeight / this.Height), targetHeight);
}
}
}

View file

@ -0,0 +1,198 @@
using System.Buffers.Binary;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using FFXIVClientStructs.FFXIV.Client.UI.Misc;
using FFXIVClientStructs.FFXIV.Component.Text;
using Lumina.Excel;
using Lumina.Excel.GeneratedSheets2;
using Lumina.Text.Expressions;
using Lumina.Text.Payloads;
using Lumina.Text.ReadOnly;
namespace Dalamud.Interface.ImGuiSeStringRenderer.Internal;
/// <summary>Color stacks to use while evaluating a SeString.</summary>
internal sealed class SeStringColorStackSet
{
/// <summary>Parsed <see cref="UIColor.UIForeground"/>, containing colors to use with
/// <see cref="MacroCode.ColorType"/>.</summary>
private readonly uint[] colorTypes;
/// <summary>Parsed <see cref="UIColor.UIGlow"/>, containing colors to use with
/// <see cref="MacroCode.EdgeColorType"/>.</summary>
private readonly uint[] edgeColorTypes;
/// <summary>Foreground color stack while evaluating a SeString for rendering.</summary>
/// <remarks>Touched only from the main thread.</remarks>
private readonly List<uint> colorStack = [];
/// <summary>Edge/border color stack while evaluating a SeString for rendering.</summary>
/// <remarks>Touched only from the main thread.</remarks>
private readonly List<uint> edgeColorStack = [];
/// <summary>Shadow color stack while evaluating a SeString for rendering.</summary>
/// <remarks>Touched only from the main thread.</remarks>
private readonly List<uint> shadowColorStack = [];
/// <summary>Initializes a new instance of the <see cref="SeStringColorStackSet"/> class.</summary>
/// <param name="uiColor">UIColor sheet.</param>
public SeStringColorStackSet(ExcelSheet<UIColor> uiColor)
{
var maxId = 0;
foreach (var row in uiColor)
maxId = (int)Math.Max(row.RowId, maxId);
this.colorTypes = new uint[maxId + 1];
this.edgeColorTypes = new uint[maxId + 1];
foreach (var row in uiColor)
{
// Contains ABGR.
this.colorTypes[row.RowId] = row.UIForeground;
this.edgeColorTypes[row.RowId] = row.UIGlow;
}
if (BitConverter.IsLittleEndian)
{
// ImGui wants RGBA in LE.
foreach (ref var r in this.colorTypes.AsSpan())
r = BinaryPrimitives.ReverseEndianness(r);
foreach (ref var r in this.edgeColorTypes.AsSpan())
r = BinaryPrimitives.ReverseEndianness(r);
}
}
/// <summary>Gets a value indicating whether at least one color has been pushed to the edge color stack.</summary>
public bool HasAdditionalEdgeColor { get; private set; }
/// <summary>Resets the colors in the stack.</summary>
/// <param name="drawState">Draw state.</param>
internal void Initialize(scoped ref SeStringDrawState drawState)
{
this.colorStack.Clear();
this.edgeColorStack.Clear();
this.shadowColorStack.Clear();
this.colorStack.Add(drawState.Color);
this.edgeColorStack.Add(drawState.EdgeColor);
this.shadowColorStack.Add(drawState.ShadowColor);
drawState.Color = ColorHelpers.ApplyOpacity(drawState.Color, drawState.Opacity);
drawState.EdgeColor = ColorHelpers.ApplyOpacity(drawState.EdgeColor, drawState.EdgeOpacity);
drawState.ShadowColor = ColorHelpers.ApplyOpacity(drawState.ShadowColor, drawState.Opacity);
}
/// <summary>Handles a <see cref="MacroCode.Color"/> payload.</summary>
/// <param name="drawState">Draw state.</param>
/// <param name="payload">Payload to handle.</param>
internal void HandleColorPayload(scoped ref SeStringDrawState drawState, ReadOnlySePayloadSpan payload) =>
drawState.Color = ColorHelpers.ApplyOpacity(AdjustStack(this.colorStack, payload), drawState.Opacity);
/// <summary>Handles a <see cref="MacroCode.EdgeColor"/> payload.</summary>
/// <param name="drawState">Draw state.</param>
/// <param name="payload">Payload to handle.</param>
internal void HandleEdgeColorPayload(
scoped ref SeStringDrawState drawState,
ReadOnlySePayloadSpan payload)
{
var newColor = AdjustStack(this.edgeColorStack, payload);
if (!drawState.ForceEdgeColor)
drawState.EdgeColor = ColorHelpers.ApplyOpacity(newColor, drawState.EdgeOpacity);
this.HasAdditionalEdgeColor = this.edgeColorStack.Count > 1;
}
/// <summary>Handles a <see cref="MacroCode.ShadowColor"/> payload.</summary>
/// <param name="drawState">Draw state.</param>
/// <param name="payload">Payload to handle.</param>
internal void HandleShadowColorPayload(
scoped ref SeStringDrawState drawState,
ReadOnlySePayloadSpan payload) =>
drawState.ShadowColor = ColorHelpers.ApplyOpacity(AdjustStack(this.shadowColorStack, payload), drawState.Opacity);
/// <summary>Handles a <see cref="MacroCode.ColorType"/> payload.</summary>
/// <param name="drawState">Draw state.</param>
/// <param name="payload">Payload to handle.</param>
internal void HandleColorTypePayload(
scoped ref SeStringDrawState drawState,
ReadOnlySePayloadSpan payload) =>
drawState.Color = ColorHelpers.ApplyOpacity(AdjustStack(this.colorStack, this.colorTypes, payload), drawState.Opacity);
/// <summary>Handles a <see cref="MacroCode.EdgeColorType"/> payload.</summary>
/// <param name="drawState">Draw state.</param>
/// <param name="payload">Payload to handle.</param>
internal void HandleEdgeColorTypePayload(
scoped ref SeStringDrawState drawState,
ReadOnlySePayloadSpan payload)
{
var newColor = AdjustStack(this.edgeColorStack, this.edgeColorTypes, payload);
if (!drawState.ForceEdgeColor)
drawState.EdgeColor = ColorHelpers.ApplyOpacity(newColor, drawState.EdgeOpacity);
this.HasAdditionalEdgeColor = this.edgeColorStack.Count > 1;
}
/// <summary>Swaps red and blue channels of a given color in ARGB(BB GG RR AA) and ABGR(RR GG BB AA).</summary>
/// <param name="x">Color to process.</param>
/// <returns>Swapped color.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static uint SwapRedBlue(uint x) => (x & 0xFF00FF00u) | ((x >> 16) & 0xFF) | ((x & 0xFF) << 16);
private static unsafe uint AdjustStack(List<uint> rgbaStack, ReadOnlySePayloadSpan payload)
{
if (!payload.TryGetExpression(out var expr))
return rgbaStack[^1];
// Color payloads have BGRA values as its parameter. ImGui expects RGBA values.
// Opacity component is ignored.
if (expr.TryGetPlaceholderExpression(out var p) && p == (int)ExpressionType.StackColor)
{
// First item in the stack is the color we started to draw with.
if (rgbaStack.Count > 1)
rgbaStack.RemoveAt(rgbaStack.Count - 1);
return rgbaStack[^1];
}
if (expr.TryGetUInt(out var bgra))
{
rgbaStack.Add(SwapRedBlue(bgra) | 0xFF000000u);
return rgbaStack[^1];
}
if (expr.TryGetParameterExpression(out var et, out var op) &&
et == (int)ExpressionType.GlobalNumber &&
op.TryGetInt(out var i) &&
RaptureTextModule.Instance() is var rtm &&
rtm is not null &&
i > 0 && i <= rtm->TextModule.MacroDecoder.GlobalParameters.Count &&
rtm->TextModule.MacroDecoder.GlobalParameters[i - 1] is { Type: TextParameterType.Integer } gp)
{
rgbaStack.Add(SwapRedBlue((uint)gp.IntValue) | 0xFF000000u);
return rgbaStack[^1];
}
// Fallback value.
rgbaStack.Add(0xFF000000u);
return rgbaStack[^1];
}
private static uint AdjustStack(List<uint> rgbaStack, uint[] colorTypes, ReadOnlySePayloadSpan payload)
{
if (!payload.TryGetExpression(out var expr))
return rgbaStack[^1];
if (!expr.TryGetUInt(out var colorTypeIndex))
return rgbaStack[^1];
if (colorTypeIndex == 0)
{
// First item in the stack is the color we started to draw with.
if (rgbaStack.Count > 1)
rgbaStack.RemoveAt(rgbaStack.Count - 1);
return rgbaStack[^1];
}
// Opacity component is ignored.
rgbaStack.Add((colorTypeIndex < colorTypes.Length ? colorTypes[colorTypeIndex] : 0u) | 0xFF000000u);
return rgbaStack[^1];
}
}

View file

@ -1,5 +1,4 @@
using System.Buffers;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.Numerics;
using System.Runtime.InteropServices;
@ -18,12 +17,10 @@ using Dalamud.Utility;
using FFXIVClientStructs.FFXIV.Client.System.String;
using FFXIVClientStructs.FFXIV.Client.UI;
using FFXIVClientStructs.FFXIV.Client.UI.Misc;
using FFXIVClientStructs.FFXIV.Component.Text;
using ImGuiNET;
using Lumina.Excel.GeneratedSheets2;
using Lumina.Text.Expressions;
using Lumina.Text.Payloads;
using Lumina.Text.ReadOnly;
@ -37,13 +34,6 @@ namespace Dalamud.Interface.ImGuiSeStringRenderer.Internal;
[ServiceManager.EarlyLoadedService]
internal unsafe class SeStringRenderer : IInternalDisposableService
{
private const int ChannelLinkBackground = 0;
private const int ChannelShadow = 1;
private const int ChannelLinkUnderline = 2;
private const int ChannelEdge = 3;
private const int ChannelFore = 4;
private const int ChannelCount = 5;
private const int ImGuiContextCurrentWindowOffset = 0x3FF0;
private const int ImGuiWindowDcOffset = 0x118;
private const int ImGuiWindowTempDataCurrLineTextBaseOffset = 0x38;
@ -83,29 +73,13 @@ internal unsafe class SeStringRenderer : IInternalDisposableService
/// <summary>Parsed <c>gfdata.gfd</c> file, containing bitmap font icon lookup table.</summary>
private readonly GfdFile gfd;
/// <summary>Parsed <see cref="UIColor.UIForeground"/>, containing colors to use with
/// <see cref="MacroCode.ColorType"/>.</summary>
private readonly uint[] colorTypes;
/// <summary>Parsed <see cref="UIColor.UIGlow"/>, containing colors to use with
/// <see cref="MacroCode.EdgeColorType"/>.</summary>
private readonly uint[] edgeColorTypes;
/// <summary>Parsed text fragments from a SeString.</summary>
/// <remarks>Touched only from the main thread.</remarks>
private readonly List<TextFragment> fragments = [];
/// <summary>Foreground color stack while evaluating a SeString for rendering.</summary>
/// <summary>Color stacks to use while evaluating a SeString for rendering.</summary>
/// <remarks>Touched only from the main thread.</remarks>
private readonly List<uint> colorStack = [];
/// <summary>Edge/border color stack while evaluating a SeString for rendering.</summary>
/// <remarks>Touched only from the main thread.</remarks>
private readonly List<uint> edgeColorStack = [];
/// <summary>Shadow color stack while evaluating a SeString for rendering.</summary>
/// <remarks>Touched only from the main thread.</remarks>
private readonly List<uint> shadowColorStack = [];
private readonly SeStringColorStackSet colorStackSet;
/// <summary>Splits a draw list so that different layers of a single glyph can be drawn out of order.</summary>
private ImDrawListSplitter* splitter = ImGuiNative.ImDrawListSplitter_ImDrawListSplitter();
@ -113,29 +87,8 @@ internal unsafe class SeStringRenderer : IInternalDisposableService
[ServiceManager.ServiceConstructor]
private SeStringRenderer(DataManager dm, TargetSigScanner sigScanner)
{
var uiColor = dm.Excel.GetSheet<UIColor>()!;
var maxId = 0;
foreach (var row in uiColor)
maxId = (int)Math.Max(row.RowId, maxId);
this.colorTypes = new uint[maxId + 1];
this.edgeColorTypes = new uint[maxId + 1];
foreach (var row in uiColor)
{
// Contains ABGR.
this.colorTypes[row.RowId] = row.UIForeground;
this.edgeColorTypes[row.RowId] = row.UIGlow;
}
if (BitConverter.IsLittleEndian)
{
// ImGui wants RGBA in LE.
foreach (ref var r in this.colorTypes.AsSpan())
r = BinaryPrimitives.ReverseEndianness(r);
foreach (ref var r in this.edgeColorTypes.AsSpan())
r = BinaryPrimitives.ReverseEndianness(r);
}
this.colorStackSet = new(
dm.Excel.GetSheet<UIColor>() ?? throw new InvalidOperationException("Failed to access UIColor sheet."));
this.gfd = dm.GetFile<GfdFile>("common/font/gfdata.gfd")!;
// SetUnhandledExceptionFilter(who cares);
@ -266,19 +219,11 @@ internal unsafe class SeStringRenderer : IInternalDisposableService
throw new ArgumentException("ImGuiId cannot be set if TargetDrawList is manually set.", nameof(imGuiId));
// This also does argument validation for drawParams. Do it here.
var state = new DrawState(sss, new(drawParams), this.splitter);
var state = new SeStringDrawState(sss, drawParams, this.colorStackSet, this.splitter);
// Reset and initialize the state.
this.fragments.Clear();
this.colorStack.Clear();
this.edgeColorStack.Clear();
this.shadowColorStack.Clear();
this.colorStack.Add(state.Params.Color);
this.edgeColorStack.Add(state.Params.EdgeColor);
this.shadowColorStack.Add(state.Params.ShadowColor);
state.Params.Color = ApplyOpacityValue(state.Params.Color, state.Params.Opacity);
state.Params.EdgeColor = ApplyOpacityValue(state.Params.EdgeColor, state.Params.EdgeOpacity);
state.Params.ShadowColor = ApplyOpacityValue(state.Params.ShadowColor, state.Params.Opacity);
this.colorStackSet.Initialize(ref state);
// Handle cases where ImGui.AlignTextToFramePadding has been called.
var pCurrentWindow = *(nint*)(ImGui.GetCurrentContext() + ImGuiContextCurrentWindowOffset);
@ -292,19 +237,22 @@ internal unsafe class SeStringRenderer : IInternalDisposableService
// Calculate size.
var size = Vector2.Zero;
foreach (ref var fragment in fragmentSpan)
size = Vector2.Max(size, fragment.Offset + new Vector2(fragment.VisibleWidth, state.Params.LineHeight));
size = Vector2.Max(size, fragment.Offset + new Vector2(fragment.VisibleWidth, state.LineHeight));
// If we're not drawing at all, stop further processing.
if (state.Params.DrawList is null)
if (state.DrawList.NativePtr is null)
return new() { Size = size };
ImGuiNative.ImDrawListSplitter_Split(state.Splitter, state.Params.DrawList, ChannelCount);
state.SplitDrawList();
// Draw all text fragments.
var lastRune = default(Rune);
foreach (ref var f in fragmentSpan)
{
var data = state.Raw.Data[f.From..f.To];
var data = state.Span[f.From..f.To];
if (f.Entity)
f.Entity.Draw(state, f.From, f.Offset);
else
this.DrawTextFragment(ref state, f.Offset, f.IsSoftHyphenVisible, data, lastRune, f.Link);
lastRune = f.LastRune;
}
@ -326,7 +274,7 @@ internal unsafe class SeStringRenderer : IInternalDisposableService
continue;
var pos = ImGui.GetMousePos() - state.ScreenOffset - f.Offset;
var sz = new Vector2(f.AdvanceWidth, state.Params.LineHeight);
var sz = new Vector2(f.AdvanceWidth, state.LineHeight);
if (pos is { X: >= 0, Y: >= 0 } && pos.X <= sz.X && pos.Y <= sz.Y)
{
invisibleButtonDrawn = true;
@ -357,26 +305,24 @@ internal unsafe class SeStringRenderer : IInternalDisposableService
// If any link is being interacted, draw rectangles behind the relevant text fragments.
if (hoveredLinkOffset != -1 || activeLinkOffset != -1)
{
state.SetCurrentChannel(ChannelLinkBackground);
var color = activeLinkOffset == -1 ? state.Params.LinkHoverBackColor : state.Params.LinkActiveBackColor;
color = ApplyOpacityValue(color, state.Params.Opacity);
state.SetCurrentChannel(SeStringDrawChannel.Background);
var color = activeLinkOffset == -1 ? state.LinkHoverBackColor : state.LinkActiveBackColor;
color = ColorHelpers.ApplyOpacity(color, state.Opacity);
foreach (ref readonly var fragment in fragmentSpan)
{
if (fragment.Link != hoveredLinkOffset && hoveredLinkOffset != -1)
continue;
if (fragment.Link != activeLinkOffset && activeLinkOffset != -1)
continue;
ImGuiNative.ImDrawList_AddRectFilled(
state.Params.DrawList,
state.ScreenOffset + fragment.Offset,
state.ScreenOffset + fragment.Offset + new Vector2(fragment.AdvanceWidth, state.Params.LineHeight),
color,
0,
ImDrawFlags.None);
var offset = state.ScreenOffset + fragment.Offset;
state.DrawList.AddRectFilled(
offset,
offset + new Vector2(fragment.AdvanceWidth, state.LineHeight),
color);
}
}
ImGuiNative.ImDrawListSplitter_Merge(state.Splitter, state.Params.DrawList);
state.MergeDrawList();
var payloadEnumerator = new ReadOnlySeStringSpan(
hoveredLinkOffset == -1 ? ReadOnlySpan<byte>.Empty : sss.Data[hoveredLinkOffset..]).GetEnumerator();
@ -412,18 +358,6 @@ internal unsafe class SeStringRenderer : IInternalDisposableService
return displayRune.Value != 0;
}
/// <summary>Swaps red and blue channels of a given color in ARGB(BB GG RR AA) and ABGR(RR GG BB AA).</summary>
/// <param name="x">Color to process.</param>
/// <returns>Swapped color.</returns>
private static uint SwapRedBlue(uint x) => (x & 0xFF00FF00u) | ((x >> 16) & 0xFF) | ((x & 0xFF) << 16);
/// <summary>Applies the given opacity value ranging from 0 to 1 to an uint value containing a RGBA value.</summary>
/// <param name="rgba">RGBA value to transform.</param>
/// <param name="opacity">Opacity to apply.</param>
/// <returns>Transformed value.</returns>
private static uint ApplyOpacityValue(uint rgba, float opacity) =>
((uint)MathF.Round((rgba >> 24) * opacity) << 24) | (rgba & 0xFFFFFFu);
private void ReleaseUnmanagedResources()
{
if (this.splitter is not null)
@ -437,25 +371,74 @@ internal unsafe class SeStringRenderer : IInternalDisposableService
/// <param name="state">Draw state.</param>
/// <param name="baseY">Y offset adjustment for all text fragments. Used to honor
/// <see cref="ImGui.AlignTextToFramePadding"/>.</param>
private void CreateTextFragments(ref DrawState state, float baseY)
private void CreateTextFragments(ref SeStringDrawState state, float baseY)
{
var prev = 0;
var xy = new Vector2(0, baseY);
var w = 0f;
var linkOffset = -1;
foreach (var (breakAt, mandatory) in new LineBreakEnumerator(state.Raw, UtfEnumeratorFlags.Utf8SeString))
var link = -1;
foreach (var (breakAt, mandatory) in new LineBreakEnumerator(state.Span, UtfEnumeratorFlags.Utf8SeString))
{
var nextLinkOffset = linkOffset;
// Might have happened if custom entity was longer than the previous break unit.
if (prev > breakAt)
continue;
var nextLink = link;
for (var first = true; prev < breakAt; first = false)
{
var curr = breakAt;
var entity = default(SeStringReplacementEntity);
// Try to split by link payloads.
foreach (var p in new ReadOnlySeStringSpan(state.Raw.Data[prev..breakAt]).GetOffsetEnumerator())
// Try to split by link payloads and custom entities.
foreach (var p in new ReadOnlySeStringSpan(state.Span[prev..breakAt]).GetOffsetEnumerator())
{
if (p.Payload.MacroCode == MacroCode.Link)
var break2 = false;
switch (p.Payload.Type)
{
nextLinkOffset =
case ReadOnlySePayloadType.Text when state.GetEntity is { } getEntity:
foreach (var oe in UtfEnumerator.From(p.Payload.Body, UtfEnumeratorFlags.Utf8))
{
var entityOffset = prev + p.Offset + oe.ByteOffset;
entity = getEntity(state, entityOffset);
if (!entity)
continue;
if (prev == entityOffset)
{
curr = entityOffset + entity.ByteLength;
}
else
{
entity = default;
curr = entityOffset;
}
break2 = true;
break;
}
break;
case ReadOnlySePayloadType.Macro when
state.GetEntity is { } getEntity &&
getEntity(state, prev + p.Offset) is { ByteLength: > 0 } entity1:
entity = entity1;
if (p.Offset == 0)
{
curr = prev + p.Offset + entity.ByteLength;
}
else
{
entity = default;
curr = prev + p.Offset;
}
break2 = true;
break;
case ReadOnlySePayloadType.Macro when p.Payload.MacroCode == MacroCode.Link:
{
nextLink =
p.Payload.TryGetExpression(out var e) &&
e.TryGetUInt(out var u) &&
u == (uint)LinkMacroPayloadType.Terminator
@ -466,37 +449,55 @@ internal unsafe class SeStringRenderer : IInternalDisposableService
if (p.Offset != 0)
{
curr = prev + p.Offset;
break2 = true;
break;
}
linkOffset = nextLinkOffset;
link = nextLink;
break;
}
case ReadOnlySePayloadType.Invalid:
default:
break;
}
if (break2) break;
}
// Create a text fragment without applying wrap width limits for testing.
var fragment = state.CreateFragment(this, prev, curr, curr == breakAt && mandatory, xy, linkOffset);
var overflows = Math.Max(w, xy.X + fragment.VisibleWidth) > state.Params.WrapWidth;
var fragment = this.CreateFragment(state, prev, curr, curr == breakAt && mandatory, xy, link, entity);
var overflows = Math.Max(w, xy.X + fragment.VisibleWidth) > state.WrapWidth;
// Test if the fragment does not fit into the current line and the current line is not empty,
// if this is the first time testing the current break unit.
if (first && xy.X != 0 && this.fragments.Count > 0 && !this.fragments[^1].BreakAfter && overflows)
// Test if the fragment does not fit into the current line and the current line is not empty.
if (xy.X != 0 && this.fragments.Count > 0 && !this.fragments[^1].BreakAfter && overflows)
{
// Introduce break if this is the first time testing the current break unit or the current fragment
// is an entity.
if (first || entity)
{
// The break unit as a whole does not fit into the current line. Advance to the next line.
xy.X = 0;
xy.Y += state.Params.LineHeight;
xy.Y += state.LineHeight;
w = 0;
CollectionsMarshal.AsSpan(this.fragments)[^1].BreakAfter = true;
fragment.Offset = xy;
// Now that the fragment is given its own line, test if it overflows again.
overflows = fragment.VisibleWidth > state.Params.WrapWidth;
overflows = fragment.VisibleWidth > state.WrapWidth;
}
}
if (overflows)
{
// A replacement entity may not be broken down further.
if (!entity)
{
// Create a fragment again that fits into the given width limit.
var remainingWidth = state.Params.WrapWidth - xy.X;
fragment = state.CreateFragment(this, prev, curr, true, xy, linkOffset, remainingWidth);
var remainingWidth = state.WrapWidth - xy.X;
fragment = this.CreateFragment(state, prev, curr, true, xy, link, entity, remainingWidth);
}
}
else if (this.fragments.Count > 0 && xy.X != 0)
{
@ -507,13 +508,13 @@ internal unsafe class SeStringRenderer : IInternalDisposableService
xy.X += this.fragments[^1].AdvanceWidthWithoutSoftHyphen - this.fragments[^1].AdvanceWidth;
// Adjust this fragment's offset from kerning distance.
xy.X += state.CalculateDistance(this.fragments[^1].LastRune, fragment.FirstRune);
xy.X += state.CalculateScaledDistance(this.fragments[^1].LastRune, fragment.FirstRune);
fragment.Offset = xy;
}
// If the fragment was not broken by wrap width, update the link payload offset.
if (fragment.To == curr)
linkOffset = nextLinkOffset;
link = nextLink;
w = Math.Max(w, xy.X + fragment.VisibleWidth);
xy.X += fragment.AdvanceWidth;
@ -523,7 +524,7 @@ internal unsafe class SeStringRenderer : IInternalDisposableService
if (fragment.BreakAfter)
{
xy.X = w = 0;
xy.Y += state.Params.LineHeight;
xy.Y += state.LineHeight;
}
}
}
@ -537,9 +538,9 @@ internal unsafe class SeStringRenderer : IInternalDisposableService
/// <param name="span">Byte span of the SeString fragment to draw.</param>
/// <param name="lastRune">Rune that preceded this text fragment in the same line, or <c>0</c> if none.</param>
/// <param name="link">Byte offset of the link payload that decorates this text fragment in
/// <see cref="DrawState.Raw"/>, or <c>-1</c> if none.</param>
/// <see cref="SeStringDrawState.Span"/>, or <c>-1</c> if none.</param>
private void DrawTextFragment(
ref DrawState state,
ref SeStringDrawState state,
Vector2 offset,
bool displaySoftHyphen,
ReadOnlySpan<byte> span,
@ -559,186 +560,47 @@ internal unsafe class SeStringRenderer : IInternalDisposableService
if (!enu.MoveNext())
continue;
var payload = enu.Current.Payload;
switch (payload.MacroCode)
{
case MacroCode.Color:
state.Params.Color = ApplyOpacityValue(
TouchColorStack(this.colorStack, payload),
state.Params.Opacity);
continue;
case MacroCode.EdgeColor:
state.Params.EdgeColor = TouchColorStack(this.edgeColorStack, payload);
state.Params.EdgeColor = ApplyOpacityValue(
state.Params.ForceEdgeColor ? this.edgeColorStack[0] : state.Params.EdgeColor,
state.Params.EdgeOpacity);
continue;
case MacroCode.ShadowColor:
state.Params.ShadowColor = ApplyOpacityValue(
TouchColorStack(this.shadowColorStack, payload),
state.Params.Opacity);
continue;
case MacroCode.Bold when payload.TryGetExpression(out var e) && e.TryGetUInt(out var u):
// doesn't actually work in chat log
state.Params.Bold = u != 0;
continue;
case MacroCode.Italic when payload.TryGetExpression(out var e) && e.TryGetUInt(out var u):
state.Params.Italic = u != 0;
continue;
case MacroCode.Edge when payload.TryGetExpression(out var e) && e.TryGetUInt(out var u):
state.Params.Edge = u != 0;
continue;
case MacroCode.Shadow when payload.TryGetExpression(out var e) && e.TryGetUInt(out var u):
state.Params.Shadow = u != 0;
continue;
case MacroCode.ColorType:
state.Params.Color = ApplyOpacityValue(
TouchColorTypeStack(this.colorStack, this.colorTypes, payload),
state.Params.Opacity);
continue;
case MacroCode.EdgeColorType:
state.Params.EdgeColor = TouchColorTypeStack(this.edgeColorStack, this.edgeColorTypes, payload);
state.Params.EdgeColor = ApplyOpacityValue(
state.Params.ForceEdgeColor ? this.edgeColorStack[0] : state.Params.EdgeColor,
state.Params.EdgeOpacity);
continue;
case MacroCode.Icon:
case MacroCode.Icon2:
{
if (this.GetBitmapFontIconFor(span[c.ByteOffset..]) is not (var icon and not None) ||
!this.gfd.TryGetEntry((uint)icon, out var gfdEntry) ||
gfdEntry.IsEmpty)
if (state.HandleStyleAdjustingPayloads(enu.Current.Payload))
continue;
var size = state.CalculateGfdEntrySize(gfdEntry, out var useHq);
state.SetCurrentChannel(ChannelFore);
if (this.GetBitmapFontIconFor(span[c.ByteOffset..]) is var icon and not None &&
this.gfd.TryGetEntry((uint)icon, out var gfdEntry) &&
!gfdEntry.IsEmpty)
{
var size = gfdEntry.CalculateScaledSize(state.FontSize, out var useHq);
state.SetCurrentChannel(SeStringDrawChannel.Foreground);
state.Draw(
offset + new Vector2(x, MathF.Round((state.Params.LineHeight - size.Y) / 2)),
gfdTextureSrv,
Vector2.Zero,
offset + new Vector2(x, MathF.Round((state.LineHeight - size.Y) / 2)),
size,
Vector2.Zero,
useHq ? gfdEntry.HqUv0 : gfdEntry.Uv0,
useHq ? gfdEntry.HqUv1 : gfdEntry.Uv1,
ApplyOpacityValue(uint.MaxValue, state.Params.Opacity));
ColorHelpers.ApplyOpacity(uint.MaxValue, state.Opacity));
if (link != -1)
state.DrawLinkUnderline(offset + new Vector2(x, 0), size.X);
width = Math.Max(width, x + size.X);
x += MathF.Round(size.X);
lastRune = default;
continue;
}
default:
continue;
}
}
if (!TryGetDisplayRune(c.EffectiveRune, out var rune, displaySoftHyphen))
continue;
ref var g = ref state.FindGlyph(ref rune);
var dist = state.CalculateDistance(lastRune, rune);
var dist = state.CalculateScaledDistance(lastRune, rune);
var advanceWidth = MathF.Round(g.AdvanceX * state.FontSizeScale);
lastRune = rune;
var dxBold = state.Params.Bold ? 2 : 1;
var dyItalic = state.Params.Italic
? new Vector2(state.Params.FontSize - g.Y0, state.Params.FontSize - g.Y1) / 6
: Vector2.Zero;
if (state.Params is { Shadow: true, ShadowColor: >= 0x1000000 })
{
state.SetCurrentChannel(ChannelShadow);
for (var dx = 0; dx < dxBold; dx++)
state.Draw(offset + new Vector2(x + dist + dx, 1), g, dyItalic, state.Params.ShadowColor);
}
if ((state.Params.Edge || this.edgeColorStack.Count > 1) && state.Params.EdgeColor >= 0x1000000)
{
state.SetCurrentChannel(ChannelEdge);
for (var dx = -1; dx <= dxBold; dx++)
{
for (var dy = -1; dy <= 1; dy++)
{
if (dx >= 0 && dx < dxBold && dy == 0)
continue;
state.Draw(offset + new Vector2(x + dist + dx, dy), g, dyItalic, state.Params.EdgeColor);
}
}
}
state.SetCurrentChannel(ChannelFore);
for (var dx = 0; dx < dxBold; dx++)
state.Draw(offset + new Vector2(x + dist + dx, 0), g, dyItalic, state.Params.Color);
state.DrawGlyph(g, offset + new Vector2(x + dist, 0));
if (link != -1)
state.DrawLinkUnderline(offset + new Vector2(x + dist, 0), g.AdvanceX);
state.DrawLinkUnderline(offset + new Vector2(x + dist, 0), advanceWidth);
width = Math.Max(width, x + dist + (g.X1 * state.FontSizeScale));
x += dist + MathF.Round(g.AdvanceX * state.FontSizeScale);
}
return;
static uint TouchColorStack(List<uint> rgbaStack, ReadOnlySePayloadSpan payload)
{
if (!payload.TryGetExpression(out var expr))
return rgbaStack[^1];
// Color payloads have BGRA values as its parameter. ImGui expects RGBA values.
// Opacity component is ignored.
if (expr.TryGetPlaceholderExpression(out var p) && p == (int)ExpressionType.StackColor)
{
// First item in the stack is the color we started to draw with.
if (rgbaStack.Count > 1)
rgbaStack.RemoveAt(rgbaStack.Count - 1);
return rgbaStack[^1];
}
if (expr.TryGetUInt(out var bgra))
{
rgbaStack.Add(SwapRedBlue(bgra) | 0xFF000000u);
return rgbaStack[^1];
}
if (expr.TryGetParameterExpression(out var et, out var op) &&
et == (int)ExpressionType.GlobalNumber &&
op.TryGetInt(out var i) &&
RaptureTextModule.Instance() is var rtm &&
rtm is not null &&
i > 0 && i <= rtm->TextModule.MacroDecoder.GlobalParameters.Count &&
rtm->TextModule.MacroDecoder.GlobalParameters[i - 1] is { Type: TextParameterType.Integer } gp)
{
rgbaStack.Add(SwapRedBlue((uint)gp.IntValue) | 0xFF000000u);
return rgbaStack[^1];
}
// Fallback value.
rgbaStack.Add(0xFF000000u);
return rgbaStack[^1];
}
static uint TouchColorTypeStack(List<uint> rgbaStack, uint[] colorTypes, ReadOnlySePayloadSpan payload)
{
if (!payload.TryGetExpression(out var expr))
return rgbaStack[^1];
if (!expr.TryGetUInt(out var colorTypeIndex))
return rgbaStack[^1];
if (colorTypeIndex == 0)
{
// First item in the stack is the color we started to draw with.
if (rgbaStack.Count > 1)
rgbaStack.RemoveAt(rgbaStack.Count - 1);
return rgbaStack[^1];
}
// Opacity component is ignored.
rgbaStack.Add((colorTypeIndex < colorTypes.Length ? colorTypes[colorTypeIndex] : 0u) | 0xFF000000u);
return rgbaStack[^1];
x += dist + advanceWidth;
}
}
@ -831,194 +693,48 @@ internal unsafe class SeStringRenderer : IInternalDisposableService
return None;
}
/// <summary>Represents a text fragment in a SeString span.</summary>
/// <param name="From">Starting byte offset (inclusive) in a SeString.</param>
/// <param name="To">Ending byte offset (exclusive) in a SeString.</param>
/// <param name="Link">Byte offset of the link that decorates this text fragment, or <c>-1</c> if none.</param>
/// <param name="Offset">Offset in pixels w.r.t. <see cref="SeStringDrawParams.ScreenOffset"/>.</param>
/// <param name="VisibleWidth">Visible width of this text fragment. This is the width required to draw everything
/// without clipping.</param>
/// <param name="AdvanceWidth">Advance width of this text fragment. This is the width required to add to the cursor
/// to position the next fragment correctly.</param>
/// <param name="AdvanceWidthWithoutSoftHyphen">Same with <paramref name="AdvanceWidth"/>, but trimming all the
/// trailing soft hyphens.</param>
/// <param name="BreakAfter">Whether to insert a line break after this text fragment.</param>
/// <param name="EndsWithSoftHyphen">Whether this text fragment ends with one or more soft hyphens.</param>
/// <param name="FirstRune">First rune in this text fragment.</param>
/// <param name="LastRune">Last rune in this text fragment, for the purpose of calculating kerning distance with
/// the following text fragment in the same line, if any.</param>
private record struct TextFragment(
int From,
int To,
int Link,
Vector2 Offset,
float VisibleWidth,
float AdvanceWidth,
float AdvanceWidthWithoutSoftHyphen,
bool BreakAfter,
bool EndsWithSoftHyphen,
Rune FirstRune,
Rune LastRune)
{
public bool IsSoftHyphenVisible => this.EndsWithSoftHyphen && this.BreakAfter;
}
/// <summary>Represents a temporary state required for drawing.</summary>
private ref struct DrawState(
ReadOnlySeStringSpan raw,
SeStringDrawParams.Resolved @params,
ImDrawListSplitter* splitter)
{
/// <summary>Raw SeString span.</summary>
public readonly ReadOnlySeStringSpan Raw = raw;
/// <summary>Multiplier value for glyph metrics, so that it scales to <see cref="SeStringDrawParams.FontSize"/>.
/// </summary>
public readonly float FontSizeScale = @params.FontSize / @params.Font->FontSize;
/// <summary>Value obtained from <see cref="ImGui.GetCursorScreenPos"/>.</summary>
public readonly Vector2 ScreenOffset = @params.ScreenOffset;
/// <summary>Splitter to split <see cref="SeStringDrawParams.TargetDrawList"/>.</summary>
public readonly ImDrawListSplitter* Splitter = splitter;
/// <summary>Resolved draw parameters from the caller.</summary>
public SeStringDrawParams.Resolved Params = @params;
/// <summary>Calculates the size in pixels of a GFD entry when drawn.</summary>
/// <param name="gfdEntry">GFD entry to determine the size.</param>
/// <param name="useHq">Whether to draw the HQ texture.</param>
/// <returns>Determined size of the GFD entry when drawn.</returns>
public readonly Vector2 CalculateGfdEntrySize(scoped in GfdFile.GfdEntry gfdEntry, out bool useHq)
{
useHq = this.Params.FontSize > 20;
var targetHeight = useHq ? this.Params.FontSize : 20;
return new(gfdEntry.Width * (targetHeight / gfdEntry.Height), targetHeight);
}
/// <summary>Sets the current channel in the ImGui draw list splitter.</summary>
/// <param name="channelIndex">Channel to switch to.</param>
public readonly void SetCurrentChannel(int channelIndex) =>
ImGuiNative.ImDrawListSplitter_SetCurrentChannel(
this.Splitter,
this.Params.DrawList,
channelIndex);
/// <summary>Draws a single glyph.</summary>
/// <param name="offset">Offset of the glyph in pixels w.r.t.
/// <see cref="SeStringDrawParams.ScreenOffset"/>.</param>
/// <param name="g">Glyph to draw.</param>
/// <param name="dyItalic">Transformation for <paramref name="g"/> that will push top and bottom pixels to
/// apply faux italicization.</param>
/// <param name="color">Color of the glyph.</param>
public readonly void Draw(
Vector2 offset,
scoped in ImGuiHelpers.ImFontGlyphReal g,
Vector2 dyItalic,
uint color) =>
this.Draw(
offset + new Vector2(
0,
MathF.Round(((this.Params.LineHeight - this.Params.Font->FontSize) * this.FontSizeScale) / 2f)),
this.Params.Font->ContainerAtlas->Textures.Ref<ImFontAtlasTexture>(g.TextureIndex).TexID,
g.XY0 * this.FontSizeScale,
g.XY1 * this.FontSizeScale,
dyItalic * this.FontSizeScale,
g.UV0,
g.UV1,
color);
/// <summary>Draws a single glyph.</summary>
/// <param name="offset">Offset of the glyph in pixels w.r.t.
/// <see cref="SeStringDrawParams.ScreenOffset"/>.</param>
/// <param name="igTextureId">ImGui texture ID to draw from.</param>
/// <param name="xy0">Left top corner of the glyph w.r.t. its glyph origin in the target draw list.</param>
/// <param name="xy1">Right bottom corner of the glyph w.r.t. its glyph origin in the target draw list.</param>
/// <param name="dyItalic">Transformation for <paramref name="xy0"/> and <paramref name="xy1"/> that will push
/// top and bottom pixels to apply faux italicization.</param>
/// <param name="uv0">Left top corner of the glyph w.r.t. its glyph origin in the source texture.</param>
/// <param name="uv1">Right bottom corner of the glyph w.r.t. its glyph origin in the source texture.</param>
/// <param name="color">Color of the glyph.</param>
public readonly void Draw(
Vector2 offset,
nint igTextureId,
Vector2 xy0,
Vector2 xy1,
Vector2 dyItalic,
Vector2 uv0,
Vector2 uv1,
uint color = uint.MaxValue)
{
offset += this.ScreenOffset;
ImGuiNative.ImDrawList_AddImageQuad(
this.Params.DrawList,
igTextureId,
offset + new Vector2(xy0.X + dyItalic.X, xy0.Y),
offset + new Vector2(xy0.X + dyItalic.Y, xy1.Y),
offset + new Vector2(xy1.X + dyItalic.Y, xy1.Y),
offset + new Vector2(xy1.X + dyItalic.X, xy0.Y),
new(uv0.X, uv0.Y),
new(uv0.X, uv1.Y),
new(uv1.X, uv1.Y),
new(uv1.X, uv0.Y),
color);
}
/// <summary>Draws an underline, for links.</summary>
/// <param name="offset">Offset of the glyph in pixels w.r.t.
/// <see cref="SeStringDrawParams.ScreenOffset"/>.</param>
/// <param name="advanceWidth">Advance width of the glyph.</param>
public readonly void DrawLinkUnderline(Vector2 offset, float advanceWidth)
{
if (this.Params.LinkUnderlineThickness < 1f)
return;
var dy = (this.Params.LinkUnderlineThickness - 1) / 2f;
dy += MathF.Round(
(((this.Params.LineHeight - this.Params.FontSize) / 2) + this.Params.Font->Ascent) *
this.FontSizeScale);
this.SetCurrentChannel(ChannelLinkUnderline);
ImGuiNative.ImDrawList_AddLine(
this.Params.DrawList,
this.ScreenOffset + offset + new Vector2(0, dy),
this.ScreenOffset + offset + new Vector2(advanceWidth, dy),
this.Params.Color,
this.Params.LinkUnderlineThickness);
if (this.Params is { Shadow: true, ShadowColor: >= 0x1000000 })
{
this.SetCurrentChannel(ChannelShadow);
ImGuiNative.ImDrawList_AddLine(
this.Params.DrawList,
this.ScreenOffset + offset + new Vector2(0, dy + 1),
this.ScreenOffset + offset + new Vector2(advanceWidth, dy + 1),
this.Params.ShadowColor,
this.Params.LinkUnderlineThickness);
}
}
/// <summary>Creates a text fragment.</summary>
/// <param name="renderer">Associated renderer.</param>
/// <param name="from">Starting byte offset (inclusive) in <see cref="Raw"/> that this fragment deals with.
/// </param>
/// <param name="to">Ending byte offset (exclusive) in <see cref="Raw"/> that this fragment deals with.</param>
/// <param name="state">Draw state.</param>
/// <param name="from">Starting byte offset (inclusive) in <see cref="SeStringDrawState.Span"/> that this fragment
/// deals with.</param>
/// <param name="to">Ending byte offset (exclusive) in <see cref="SeStringDrawState.Span"/> that this fragment deals
/// with.</param>
/// <param name="breakAfter">Whether to break line after this fragment.</param>
/// <param name="offset">Offset in pixels w.r.t. <see cref="SeStringDrawParams.ScreenOffset"/>.</param>
/// <param name="activeLinkOffset">Byte offset of the link payload in <see cref="Raw"/> that decorates this
/// text fragment.</param>
/// <param name="link">Byte offset of the link payload in <see cref="SeStringDrawState.Span"/> that
/// decorates this text fragment.</param>
/// <param name="entity">Entity to display in place of this fragment.</param>
/// <param name="wrapWidth">Optional wrap width to stop at while creating this text fragment. Note that at least
/// one visible character needs to be there in a single text fragment, in which case it is allowed to exceed
/// the wrap width.</param>
/// <returns>Newly created text fragment.</returns>
public readonly TextFragment CreateFragment(
SeStringRenderer renderer,
private TextFragment CreateFragment(
scoped in SeStringDrawState state,
int from,
int to,
bool breakAfter,
Vector2 offset,
int activeLinkOffset,
int link,
SeStringReplacementEntity entity,
float wrapWidth = float.MaxValue)
{
if (entity)
{
return new(
from,
to,
link,
offset,
entity,
entity.Size.X,
entity.Size.X,
entity.Size.X,
false,
false,
default,
default);
}
var x = 0f;
var w = 0f;
var visibleWidth = 0f;
@ -1028,19 +744,19 @@ internal unsafe class SeStringRenderer : IInternalDisposableService
var lastDisplayRune = default(Rune);
var lastNonSoftHyphenRune = default(Rune);
var endsWithSoftHyphen = false;
foreach (var c in UtfEnumerator.From(this.Raw.Data[from..to], UtfEnumeratorFlags.Utf8SeString))
foreach (var c in UtfEnumerator.From(state.Span[from..to], UtfEnumeratorFlags.Utf8SeString))
{
var byteOffset = from + c.ByteOffset;
var isBreakableWhitespace = false;
var effectiveRune = c.EffectiveRune;
Rune displayRune;
if (c is { IsSeStringPayload: true, MacroCode: MacroCode.Icon or MacroCode.Icon2 } &&
renderer.GetBitmapFontIconFor(this.Raw.Data[byteOffset..]) is var icon and not None &&
renderer.gfd.TryGetEntry((uint)icon, out var gfdEntry) &&
this.GetBitmapFontIconFor(state.Span[byteOffset..]) is var icon and not None &&
this.gfd.TryGetEntry((uint)icon, out var gfdEntry) &&
!gfdEntry.IsEmpty)
{
// This is an icon payload.
var size = this.CalculateGfdEntrySize(gfdEntry, out _);
var size = gfdEntry.CalculateScaledSize(state.FontSize, out _);
w = Math.Max(w, x + size.X);
x += MathF.Round(size.X);
displayRune = default;
@ -1048,10 +764,10 @@ internal unsafe class SeStringRenderer : IInternalDisposableService
else if (TryGetDisplayRune(effectiveRune, out displayRune))
{
// This is a printable character, or a standard whitespace character.
ref var g = ref this.FindGlyph(ref displayRune);
var dist = this.CalculateDistance(lastDisplayRune, displayRune);
w = Math.Max(w, x + ((dist + g.X1) * this.FontSizeScale));
x += MathF.Round((dist + g.AdvanceX) * this.FontSizeScale);
ref var g = ref state.FindGlyph(ref displayRune);
var dist = state.CalculateScaledDistance(lastDisplayRune, displayRune);
w = Math.Max(w, x + dist + MathF.Round(g.X1 * state.FontSizeScale));
x += dist + MathF.Round(g.AdvanceX * state.FontSizeScale);
isBreakableWhitespace =
Rune.IsWhiteSpace(displayRune) &&
@ -1091,8 +807,9 @@ internal unsafe class SeStringRenderer : IInternalDisposableService
return new(
from,
to,
activeLinkOffset,
link,
offset,
entity,
visibleWidth,
advanceWidth,
advanceWidthWithoutSoftHyphen,
@ -1102,52 +819,37 @@ internal unsafe class SeStringRenderer : IInternalDisposableService
lastNonSoftHyphenRune);
}
/// <summary>Gets the glyph corresponding to the given codepoint.</summary>
/// <param name="rune">An instance of <see cref="Rune"/> that represents a character to display.</param>
/// <returns>Corresponding glyph, or glyph of a fallback character specified from
/// <see cref="ImFont.FallbackChar"/>.</returns>
public readonly ref ImGuiHelpers.ImFontGlyphReal FindGlyph(Rune rune)
/// <summary>Represents a text fragment in a SeString span.</summary>
/// <param name="From">Starting byte offset (inclusive) in a SeString.</param>
/// <param name="To">Ending byte offset (exclusive) in a SeString.</param>
/// <param name="Link">Byte offset of the link that decorates this text fragment, or <c>-1</c> if none.</param>
/// <param name="Offset">Offset in pixels w.r.t. <see cref="SeStringDrawParams.ScreenOffset"/>.</param>
/// <param name="Entity">Replacement entity, if any.</param>
/// <param name="VisibleWidth">Visible width of this text fragment. This is the width required to draw everything
/// without clipping.</param>
/// <param name="AdvanceWidth">Advance width of this text fragment. This is the width required to add to the cursor
/// to position the next fragment correctly.</param>
/// <param name="AdvanceWidthWithoutSoftHyphen">Same with <paramref name="AdvanceWidth"/>, but trimming all the
/// trailing soft hyphens.</param>
/// <param name="BreakAfter">Whether to insert a line break after this text fragment.</param>
/// <param name="EndsWithSoftHyphen">Whether this text fragment ends with one or more soft hyphens.</param>
/// <param name="FirstRune">First rune in this text fragment.</param>
/// <param name="LastRune">Last rune in this text fragment, for the purpose of calculating kerning distance with
/// the following text fragment in the same line, if any.</param>
private record struct TextFragment(
int From,
int To,
int Link,
Vector2 Offset,
SeStringReplacementEntity Entity,
float VisibleWidth,
float AdvanceWidth,
float AdvanceWidthWithoutSoftHyphen,
bool BreakAfter,
bool EndsWithSoftHyphen,
Rune FirstRune,
Rune LastRune)
{
var p = rune.Value is >= ushort.MinValue and < ushort.MaxValue
? ImGuiNative.ImFont_FindGlyph(this.Params.Font, (ushort)rune.Value)
: this.Params.Font->FallbackGlyph;
return ref *(ImGuiHelpers.ImFontGlyphReal*)p;
}
/// <summary>Gets the glyph corresponding to the given codepoint.</summary>
/// <param name="rune">An instance of <see cref="Rune"/> that represents a character to display, that will be
/// changed on return to the rune corresponding to the fallback glyph if a glyph not corresponding to the
/// requested glyph is being returned.</param>
/// <returns>Corresponding glyph, or glyph of a fallback character specified from
/// <see cref="ImFont.FallbackChar"/>.</returns>
public readonly ref ImGuiHelpers.ImFontGlyphReal FindGlyph(ref Rune rune)
{
ref var glyph = ref this.FindGlyph(rune);
if (rune.Value != glyph.Codepoint && !Rune.TryCreate(glyph.Codepoint, out rune))
rune = Rune.ReplacementChar;
return ref glyph;
}
/// <summary>Gets the kerning adjustment between two glyphs in a succession corresponding to the given runes.
/// </summary>
/// <param name="left">Rune representing the glyph on the left side of a pair.</param>
/// <param name="right">Rune representing the glyph on the right side of a pair.</param>
/// <returns>Distance adjustment in pixels, scaled to the size specified from
/// <see cref="SeStringDrawParams.FontSize"/>, and rounded.</returns>
public readonly float CalculateDistance(Rune left, Rune right)
{
// Kerning distance entries are ignored if NUL, U+FFFF(invalid Unicode character), or characters outside
// the basic multilingual plane(BMP) is involved.
if (left.Value is <= 0 or >= char.MaxValue)
return 0;
if (right.Value is <= 0 or >= char.MaxValue)
return 0;
return MathF.Round(
ImGuiNative.ImFont_GetDistanceAdjustmentForPair(
this.Params.Font,
(ushort)left.Value,
(ushort)right.Value) * this.FontSizeScale);
}
public bool IsSoftHyphenVisible => this.EndsWithSoftHyphen && this.BreakAfter;
}
}

View file

@ -12,9 +12,11 @@ namespace Dalamud.Interface.ImGuiSeStringRenderer.Internal.TextProcessing;
internal ref struct LineBreakEnumerator
{
private readonly UtfEnumeratorFlags enumeratorFlags;
private readonly int dataLength;
private UtfEnumerator enumerator;
private int dataLength;
private int currentByteOffsetDelta;
private Entry class1;
private Entry class2;
@ -24,8 +26,6 @@ internal ref struct LineBreakEnumerator
private int consecutiveRegionalIndicators;
private bool finished;
/// <summary>Initializes a new instance of the <see cref="LineBreakEnumerator"/> struct.</summary>
/// <param name="data">UTF-N byte sequence.</param>
/// <param name="enumeratorFlags">Flags to pass to sub-enumerator.</param>
@ -58,11 +58,25 @@ internal ref struct LineBreakEnumerator
/// <inheritdoc cref="IEnumerator{T}.Current"/>
public (int ByteOffset, bool Mandatory) Current { get; private set; }
/// <summary>Gets a value indicating whether the end of the underlying span has been reached.</summary>
public bool Finished { get; private set; }
/// <summary>Resumes enumeration with the given data.</summary>
/// <param name="data">The data.</param>
/// <param name="offsetDelta">Offset to add to <see cref="Current"/>.<c>ByteOffset</c>.</param>
public void ResumeWith(ReadOnlySpan<byte> data, int offsetDelta)
{
this.enumerator = UtfEnumerator.From(data, this.enumeratorFlags);
this.dataLength = data.Length;
this.currentByteOffsetDelta = offsetDelta;
this.Finished = false;
}
/// <inheritdoc cref="IEnumerator.MoveNext"/>
[SuppressMessage("ReSharper", "ConvertIfStatementToSwitchStatement", Justification = "No")]
public bool MoveNext()
{
if (this.finished)
if (this.Finished)
return false;
while (this.enumerator.MoveNext())
@ -77,10 +91,10 @@ internal ref struct LineBreakEnumerator
switch (this.HandleCharacter(effectiveInt))
{
case LineBreakMode.Mandatory:
this.Current = (this.enumerator.Current.ByteOffset, true);
this.Current = (this.enumerator.Current.ByteOffset + this.currentByteOffsetDelta, true);
return true;
case LineBreakMode.Optional:
this.Current = (this.enumerator.Current.ByteOffset, false);
this.Current = (this.enumerator.Current.ByteOffset + this.currentByteOffsetDelta, false);
return true;
case LineBreakMode.Prohibited:
default:
@ -90,8 +104,8 @@ internal ref struct LineBreakEnumerator
// Start and end of text:
// LB3 Always break at the end of text.
this.Current = (this.dataLength, true);
this.finished = true;
this.Current = (this.dataLength + this.currentByteOffsetDelta, true);
this.Finished = true;
return true;
}

View file

@ -0,0 +1,42 @@
namespace Dalamud.Interface.ImGuiSeStringRenderer;
/// <summary>Predefined channels for drawing onto, for out-of-order drawing.</summary>
// Notes: values must be consecutively increasing, starting from 0. Higher values has higher priority.
public enum SeStringDrawChannel
{
/// <summary>Next draw operation on the draw list will be put below <see cref="Background"/>.</summary>
BelowBackground,
/// <summary>Next draw operation on the draw list will be put onto the background channel.</summary>
Background,
/// <summary>Next draw operation on the draw list will be put above <see cref="Background"/>.</summary>
AboveBackground,
/// <summary>Next draw operation on the draw list will be put below <see cref="Shadow"/>.</summary>
BelowShadow,
/// <summary>Next draw operation on the draw list will be put onto the shadow channel.</summary>
Shadow,
/// <summary>Next draw operation on the draw list will be put above <see cref="Shadow"/>.</summary>
AboveShadow,
/// <summary>Next draw operation on the draw list will be put below <see cref="Edge"/>.</summary>
BelowEdge,
/// <summary>Next draw operation on the draw list will be put onto the edge channel.</summary>
Edge,
/// <summary>Next draw operation on the draw list will be put above <see cref="Edge"/>.</summary>
AboveEdge,
/// <summary>Next draw operation on the draw list will be put below <see cref="Foreground"/>.</summary>
BelowForeground,
/// <summary>Next draw operation on the draw list will be put onto the foreground channel.</summary>
Foreground,
/// <summary>Next draw operation on the draw list will be put above <see cref="Foreground"/>.</summary>
AboveForeground,
}

View file

@ -1,6 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using System.Numerics;
using System.Runtime.InteropServices;
using ImGuiNET;
@ -18,14 +16,18 @@ public record struct SeStringDrawParams
/// </remarks>
public ImDrawListPtr? TargetDrawList { get; set; }
/// <summary>Gets or sets the font to use.</summary>
/// <value>Font to use, or <c>null</c> to use <see cref="ImGui.GetFont"/> (the default).</value>
public ImFontPtr? Font { get; set; }
/// <summary>Gets or sets the function to be called on every codepoint and payload for the purpose of offering
/// chances to draw something else instead of glyphs or SeString payload entities.</summary>
public SeStringReplacementEntity.GetEntityDelegate? GetEntity { get; set; }
/// <summary>Gets or sets the screen offset of the left top corner.</summary>
/// <value>Screen offset to draw at, or <c>null</c> to use <see cref="ImGui.GetCursorScreenPos"/>.</value>
public Vector2? ScreenOffset { get; set; }
/// <summary>Gets or sets the font to use.</summary>
/// <value>Font to use, or <c>null</c> to use <see cref="ImGui.GetFont"/> (the default).</value>
public ImFontPtr? Font { get; set; }
/// <summary>Gets or sets the font size.</summary>
/// <value>Font size in pixels, or <c>0</c> to use the current ImGui font size <see cref="ImGui.GetFontSize"/>.
/// </value>
@ -86,83 +88,23 @@ public record struct SeStringDrawParams
public bool Italic { get; set; }
/// <summary>Gets or sets a value indicating whether the text is rendered with edge.</summary>
/// <remarks>If an edge color is pushed with <see cref="MacroCode.EdgeColor"/> or
/// <see cref="MacroCode.EdgeColorType"/>, it will be drawn regardless. Set <see cref="ForceEdgeColor"/> to
/// <c>true</c> and set <see cref="EdgeColor"/> to <c>0</c> to fully disable edge.</remarks>
public bool Edge { get; set; }
/// <summary>Gets or sets a value indicating whether the text is rendered with shadow.</summary>
public bool Shadow { get; set; }
private readonly unsafe ImFont* EffectiveFont =>
/// <summary>Gets the effective font.</summary>
internal readonly unsafe ImFont* EffectiveFont =>
(this.Font ?? ImGui.GetFont()) is var f && f.NativePtr is not null
? f.NativePtr
: throw new ArgumentException("Specified font is empty.");
private readonly float EffectiveLineHeight => (this.FontSize ?? ImGui.GetFontSize()) * (this.LineHeight ?? 1f);
/// <summary>Gets the effective line height in pixels.</summary>
internal readonly float EffectiveLineHeight => (this.FontSize ?? ImGui.GetFontSize()) * (this.LineHeight ?? 1f);
private readonly float EffectiveOpacity => this.Opacity ?? ImGui.GetStyle().Alpha;
/// <summary>Calculated values from <see cref="SeStringDrawParams"/> using ImGui styles.</summary>
[SuppressMessage(
"StyleCop.CSharp.OrderingRules",
"SA1214:Readonly fields should appear before non-readonly fields",
Justification = "Matching the above order.")]
[StructLayout(LayoutKind.Sequential)]
internal unsafe struct Resolved(in SeStringDrawParams ssdp)
{
/// <inheritdoc cref="SeStringDrawParams.TargetDrawList"/>
public readonly ImDrawList* DrawList = ssdp.TargetDrawList ?? ImGui.GetWindowDrawList();
/// <inheritdoc cref="SeStringDrawParams.Font"/>
public readonly ImFont* Font = ssdp.EffectiveFont;
/// <inheritdoc cref="SeStringDrawParams.ScreenOffset"/>
public readonly Vector2 ScreenOffset = ssdp.ScreenOffset ?? ImGui.GetCursorScreenPos();
/// <inheritdoc cref="SeStringDrawParams.FontSize"/>
public readonly float FontSize = ssdp.FontSize ?? ImGui.GetFontSize();
/// <inheritdoc cref="SeStringDrawParams.LineHeight"/>
public readonly float LineHeight = MathF.Round(ssdp.EffectiveLineHeight);
/// <inheritdoc cref="SeStringDrawParams.WrapWidth"/>
public readonly float WrapWidth = ssdp.WrapWidth ?? ImGui.GetContentRegionAvail().X;
/// <inheritdoc cref="SeStringDrawParams.LinkUnderlineThickness"/>
public readonly float LinkUnderlineThickness = ssdp.LinkUnderlineThickness ?? 0f;
/// <inheritdoc cref="SeStringDrawParams.Opacity"/>
public readonly float Opacity = ssdp.EffectiveOpacity;
/// <inheritdoc cref="SeStringDrawParams.EdgeStrength"/>
public readonly float EdgeOpacity = (ssdp.EdgeStrength ?? 0.25f) * ssdp.EffectiveOpacity;
/// <inheritdoc cref="SeStringDrawParams.Color"/>
public uint Color = ssdp.Color ?? ImGui.GetColorU32(ImGuiCol.Text);
/// <inheritdoc cref="SeStringDrawParams.EdgeColor"/>
public uint EdgeColor = ssdp.EdgeColor ?? 0xFF000000;
/// <inheritdoc cref="SeStringDrawParams.ShadowColor"/>
public uint ShadowColor = ssdp.ShadowColor ?? 0xFF000000;
/// <inheritdoc cref="SeStringDrawParams.LinkHoverBackColor"/>
public readonly uint LinkHoverBackColor = ssdp.LinkHoverBackColor ?? ImGui.GetColorU32(ImGuiCol.ButtonHovered);
/// <inheritdoc cref="SeStringDrawParams.LinkActiveBackColor"/>
public readonly uint LinkActiveBackColor = ssdp.LinkActiveBackColor ?? ImGui.GetColorU32(ImGuiCol.ButtonActive);
/// <inheritdoc cref="SeStringDrawParams.ForceEdgeColor"/>
public readonly bool ForceEdgeColor = ssdp.ForceEdgeColor;
/// <inheritdoc cref="SeStringDrawParams.Bold"/>
public bool Bold = ssdp.Bold;
/// <inheritdoc cref="SeStringDrawParams.Italic"/>
public bool Italic = ssdp.Italic;
/// <inheritdoc cref="SeStringDrawParams.Edge"/>
public bool Edge = ssdp.Edge;
/// <inheritdoc cref="SeStringDrawParams.Shadow"/>
public bool Shadow = ssdp.Shadow;
}
/// <summary>Gets the effective opacity.</summary>
internal readonly float EffectiveOpacity => this.Opacity ?? ImGui.GetStyle().Alpha;
}

View file

@ -0,0 +1,400 @@
using System.Numerics;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Text;
using Dalamud.Interface.ImGuiSeStringRenderer.Internal;
using Dalamud.Interface.Utility;
using ImGuiNET;
using Lumina.Text.Payloads;
using Lumina.Text.ReadOnly;
namespace Dalamud.Interface.ImGuiSeStringRenderer;
/// <summary>Calculated values from <see cref="SeStringDrawParams"/> using ImGui styles.</summary>
[StructLayout(LayoutKind.Sequential)]
public unsafe ref struct SeStringDrawState
{
private static readonly int ChannelCount = Enum.GetValues<SeStringDrawChannel>().Length;
private readonly ImDrawList* drawList;
private readonly SeStringColorStackSet colorStackSet;
private readonly ImDrawListSplitter* splitter;
/// <summary>Initializes a new instance of the <see cref="SeStringDrawState"/> struct.</summary>
/// <param name="span">Raw SeString byte span.</param>
/// <param name="ssdp">Instance of <see cref="SeStringDrawParams"/> to initialize from.</param>
/// <param name="colorStackSet">Instance of <see cref="SeStringColorStackSet"/> to use.</param>
/// <param name="splitter">Instance of ImGui Splitter to use.</param>
internal SeStringDrawState(
ReadOnlySpan<byte> span,
scoped in SeStringDrawParams ssdp,
SeStringColorStackSet colorStackSet,
ImDrawListSplitter* splitter)
{
this.colorStackSet = colorStackSet;
this.splitter = splitter;
this.drawList = ssdp.TargetDrawList ?? ImGui.GetWindowDrawList();
this.Span = span;
this.GetEntity = ssdp.GetEntity;
this.ScreenOffset = ssdp.ScreenOffset ?? ImGui.GetCursorScreenPos();
this.ScreenOffset = new(MathF.Round(this.ScreenOffset.X), MathF.Round(this.ScreenOffset.Y));
this.Font = ssdp.EffectiveFont;
this.FontSize = ssdp.FontSize ?? ImGui.GetFontSize();
this.FontSizeScale = this.FontSize / this.Font->FontSize;
this.LineHeight = MathF.Round(ssdp.EffectiveLineHeight);
this.WrapWidth = ssdp.WrapWidth ?? ImGui.GetContentRegionAvail().X;
this.LinkUnderlineThickness = ssdp.LinkUnderlineThickness ?? 0f;
this.Opacity = ssdp.EffectiveOpacity;
this.EdgeOpacity = (ssdp.EdgeStrength ?? 0.25f) * ssdp.EffectiveOpacity;
this.Color = ssdp.Color ?? ImGui.GetColorU32(ImGuiCol.Text);
this.EdgeColor = ssdp.EdgeColor ?? 0xFF000000;
this.ShadowColor = ssdp.ShadowColor ?? 0xFF000000;
this.LinkHoverBackColor = ssdp.LinkHoverBackColor ?? ImGui.GetColorU32(ImGuiCol.ButtonHovered);
this.LinkActiveBackColor = ssdp.LinkActiveBackColor ?? ImGui.GetColorU32(ImGuiCol.ButtonActive);
this.ForceEdgeColor = ssdp.ForceEdgeColor;
this.Bold = ssdp.Bold;
this.Italic = ssdp.Italic;
this.Edge = ssdp.Edge;
this.Shadow = ssdp.Shadow;
}
/// <inheritdoc cref="SeStringDrawParams.TargetDrawList"/>
public readonly ImDrawListPtr DrawList => new(this.drawList);
/// <summary>Gets the raw SeString byte span.</summary>
public ReadOnlySpan<byte> Span { get; }
/// <inheritdoc cref="SeStringDrawParams.GetEntity"/>
public SeStringReplacementEntity.GetEntityDelegate? GetEntity { get; }
/// <inheritdoc cref="SeStringDrawParams.ScreenOffset"/>
public Vector2 ScreenOffset { get; }
/// <inheritdoc cref="SeStringDrawParams.Font"/>
public ImFont* Font { get; }
/// <inheritdoc cref="SeStringDrawParams.FontSize"/>
public float FontSize { get; }
/// <summary>Gets the multiplier value for glyph metrics, so that it scales to <see cref="FontSize"/>.</summary>
/// <remarks>Multiplied to <see cref="ImGuiHelpers.ImFontGlyphReal.XY"/>,
/// <see cref="ImGuiHelpers.ImFontGlyphReal.AdvanceX"/>, and distance values from
/// <see cref="ImFontPtr.GetDistanceAdjustmentForPair"/>.</remarks>
public float FontSizeScale { get; }
/// <inheritdoc cref="SeStringDrawParams.LineHeight"/>
public float LineHeight { get; }
/// <inheritdoc cref="SeStringDrawParams.WrapWidth"/>
public float WrapWidth { get; }
/// <inheritdoc cref="SeStringDrawParams.LinkUnderlineThickness"/>
public float LinkUnderlineThickness { get; }
/// <inheritdoc cref="SeStringDrawParams.Opacity"/>
public float Opacity { get; }
/// <inheritdoc cref="SeStringDrawParams.EdgeStrength"/>
public float EdgeOpacity { get; }
/// <inheritdoc cref="SeStringDrawParams.Color"/>
public uint Color { get; set; }
/// <inheritdoc cref="SeStringDrawParams.EdgeColor"/>
public uint EdgeColor { get; set; }
/// <inheritdoc cref="SeStringDrawParams.ShadowColor"/>
public uint ShadowColor { get; set; }
/// <inheritdoc cref="SeStringDrawParams.LinkHoverBackColor"/>
public uint LinkHoverBackColor { get; }
/// <inheritdoc cref="SeStringDrawParams.LinkActiveBackColor"/>
public uint LinkActiveBackColor { get; }
/// <inheritdoc cref="SeStringDrawParams.ForceEdgeColor"/>
public bool ForceEdgeColor { get; }
/// <inheritdoc cref="SeStringDrawParams.Bold"/>
public bool Bold { get; set; }
/// <inheritdoc cref="SeStringDrawParams.Italic"/>
public bool Italic { get; set; }
/// <inheritdoc cref="SeStringDrawParams.Edge"/>
public bool Edge { get; set; }
/// <inheritdoc cref="SeStringDrawParams.Shadow"/>
public bool Shadow { get; set; }
/// <summary>Gets a value indicating whether the edge should be drawn.</summary>
public readonly bool ShouldDrawEdge =>
(this.Edge || this.colorStackSet.HasAdditionalEdgeColor) && this.EdgeColor >= 0x1000000;
/// <summary>Gets a value indicating whether the edge should be drawn.</summary>
public readonly bool ShouldDrawShadow => this is { Shadow: true, ShadowColor: >= 0x1000000 };
/// <summary>Gets a value indicating whether the edge should be drawn.</summary>
public readonly bool ShouldDrawForeground => this is { Color: >= 0x1000000 };
/// <summary>Sets the current channel in the ImGui draw list splitter.</summary>
/// <param name="channelIndex">Channel to switch to.</param>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public readonly void SetCurrentChannel(SeStringDrawChannel channelIndex) =>
ImGuiNative.ImDrawListSplitter_SetCurrentChannel(this.splitter, this.drawList, (int)channelIndex);
/// <summary>Draws a single texture.</summary>
/// <param name="igTextureId">ImGui texture ID to draw from.</param>
/// <param name="offset">Offset of the glyph in pixels w.r.t. <see cref="ScreenOffset"/>.</param>
/// <param name="size">Right bottom corner of the glyph w.r.t. its glyph origin in the target draw list.</param>
/// <param name="uv0">Left top corner of the glyph w.r.t. its glyph origin in the source texture.</param>
/// <param name="uv1">Right bottom corner of the glyph w.r.t. its glyph origin in the source texture.</param>
/// <param name="color">Color of the glyph in RGBA.</param>
public readonly void Draw(
nint igTextureId,
Vector2 offset,
Vector2 size,
Vector2 uv0,
Vector2 uv1,
uint color = uint.MaxValue)
{
offset += this.ScreenOffset;
this.DrawList.AddImageQuad(
igTextureId,
offset,
offset + size with { X = 0 },
offset + size,
offset + size with { Y = 0 },
new(uv0.X, uv0.Y),
new(uv0.X, uv1.Y),
new(uv1.X, uv1.Y),
new(uv1.X, uv0.Y),
color);
}
/// <summary>Draws a single texture.</summary>
/// <param name="igTextureId">ImGui texture ID to draw from.</param>
/// <param name="offset">Offset of the glyph in pixels w.r.t. <see cref="ScreenOffset"/>.</param>
/// <param name="xy0">Left top corner of the glyph w.r.t. its glyph origin in the target draw list.</param>
/// <param name="xy1">Right bottom corner of the glyph w.r.t. its glyph origin in the target draw list.</param>
/// <param name="uv0">Left top corner of the glyph w.r.t. its glyph origin in the source texture.</param>
/// <param name="uv1">Right bottom corner of the glyph w.r.t. its glyph origin in the source texture.</param>
/// <param name="color">Color of the glyph in RGBA.</param>
/// <param name="dyItalic">Transformation for <paramref name="xy0"/> and <paramref name="xy1"/> that will push
/// top and bottom pixels to apply faux italicization by <see cref="Vector2.X"/> and <see cref="Vector2.Y"/>
/// respectively.</param>
public readonly void Draw(
nint igTextureId,
Vector2 offset,
Vector2 xy0,
Vector2 xy1,
Vector2 uv0,
Vector2 uv1,
uint color = uint.MaxValue,
Vector2 dyItalic = default)
{
offset += this.ScreenOffset;
this.DrawList.AddImageQuad(
igTextureId,
offset + new Vector2(xy0.X + dyItalic.X, xy0.Y),
offset + new Vector2(xy0.X + dyItalic.Y, xy1.Y),
offset + new Vector2(xy1.X + dyItalic.Y, xy1.Y),
offset + new Vector2(xy1.X + dyItalic.X, xy0.Y),
new(uv0.X, uv0.Y),
new(uv0.X, uv1.Y),
new(uv1.X, uv1.Y),
new(uv1.X, uv0.Y),
color);
}
/// <summary>Draws a single glyph using current styling configurations.</summary>
/// <param name="g">Glyph to draw.</param>
/// <param name="offset">Offset of the glyph in pixels w.r.t. <see cref="ScreenOffset"/>.</param>
internal readonly void DrawGlyph(scoped in ImGuiHelpers.ImFontGlyphReal g, Vector2 offset)
{
var texId = this.Font->ContainerAtlas->Textures.Ref<ImFontAtlasTexture>(g.TextureIndex).TexID;
var xy0 = new Vector2(
MathF.Round(g.X0 * this.FontSizeScale),
MathF.Round(g.Y0 * this.FontSizeScale));
var xy1 = new Vector2(
MathF.Round(g.X1 * this.FontSizeScale),
MathF.Round(g.Y1 * this.FontSizeScale));
var dxBold = this.Bold ? 2 : 1;
var dyItalic = this.Italic
? new Vector2(this.FontSize - xy0.Y, this.FontSize - xy1.Y) / 6
: Vector2.Zero;
// Note: dyItalic values can be non-rounded; the glyph will be rendered sheared anyway.
offset.Y += MathF.Round((this.LineHeight - this.FontSize) / 2f);
if (this.ShouldDrawShadow)
{
this.SetCurrentChannel(SeStringDrawChannel.Shadow);
for (var i = 0; i < dxBold; i++)
this.Draw(texId, offset + new Vector2(i, 1), xy0, xy1, g.UV0, g.UV1, this.ShadowColor, dyItalic);
}
if (this.ShouldDrawEdge)
{
this.SetCurrentChannel(SeStringDrawChannel.Edge);
// Top & Bottom
for (var i = -1; i <= dxBold; i++)
{
this.Draw(texId, offset + new Vector2(i, -1), xy0, xy1, g.UV0, g.UV1, this.EdgeColor, dyItalic);
this.Draw(texId, offset + new Vector2(i, 1), xy0, xy1, g.UV0, g.UV1, this.EdgeColor, dyItalic);
}
// Left & Right
this.Draw(texId, offset + new Vector2(-1, 0), xy0, xy1, g.UV0, g.UV1, this.EdgeColor, dyItalic);
this.Draw(texId, offset + new Vector2(1, 0), xy0, xy1, g.UV0, g.UV1, this.EdgeColor, dyItalic);
}
if (this.ShouldDrawForeground)
{
this.SetCurrentChannel(SeStringDrawChannel.Foreground);
for (var i = 0; i < dxBold; i++)
this.Draw(texId, offset + new Vector2(i, 0), xy0, xy1, g.UV0, g.UV1, this.Color, dyItalic);
}
}
/// <summary>Draws an underline, for links.</summary>
/// <param name="offset">Offset of the glyph in pixels w.r.t.
/// <see cref="SeStringDrawParams.ScreenOffset"/>.</param>
/// <param name="advanceWidth">Advance width of the glyph.</param>
internal readonly void DrawLinkUnderline(Vector2 offset, float advanceWidth)
{
if (this.LinkUnderlineThickness < 1f)
return;
offset += this.ScreenOffset;
offset.Y += (this.LinkUnderlineThickness - 1) / 2f;
offset.Y += MathF.Round(((this.LineHeight - this.FontSize) / 2) + (this.Font->Ascent * this.FontSizeScale));
this.SetCurrentChannel(SeStringDrawChannel.Foreground);
this.DrawList.AddLine(
offset,
offset + new Vector2(advanceWidth, 0),
this.Color,
this.LinkUnderlineThickness);
if (this is { Shadow: true, ShadowColor: >= 0x1000000 })
{
this.SetCurrentChannel(SeStringDrawChannel.Shadow);
this.DrawList.AddLine(
offset + new Vector2(0, 1),
offset + new Vector2(advanceWidth, 1),
this.ShadowColor,
this.LinkUnderlineThickness);
}
}
/// <summary>Gets the glyph corresponding to the given codepoint.</summary>
/// <param name="rune">An instance of <see cref="Rune"/> that represents a character to display.</param>
/// <returns>Corresponding glyph, or glyph of a fallback character specified from
/// <see cref="ImFont.FallbackChar"/>.</returns>
internal readonly ref ImGuiHelpers.ImFontGlyphReal FindGlyph(Rune rune)
{
var p = rune.Value is >= ushort.MinValue and < ushort.MaxValue
? ImGuiNative.ImFont_FindGlyph(this.Font, (ushort)rune.Value)
: this.Font->FallbackGlyph;
return ref *(ImGuiHelpers.ImFontGlyphReal*)p;
}
/// <summary>Gets the glyph corresponding to the given codepoint.</summary>
/// <param name="rune">An instance of <see cref="Rune"/> that represents a character to display, that will be
/// changed on return to the rune corresponding to the fallback glyph if a glyph not corresponding to the
/// requested glyph is being returned.</param>
/// <returns>Corresponding glyph, or glyph of a fallback character specified from
/// <see cref="ImFont.FallbackChar"/>.</returns>
internal readonly ref ImGuiHelpers.ImFontGlyphReal FindGlyph(ref Rune rune)
{
ref var glyph = ref this.FindGlyph(rune);
if (rune.Value != glyph.Codepoint && !Rune.TryCreate(glyph.Codepoint, out rune))
rune = Rune.ReplacementChar;
return ref glyph;
}
/// <summary>Gets the kerning adjustment between two glyphs in a succession corresponding to the given runes.
/// </summary>
/// <param name="left">Rune representing the glyph on the left side of a pair.</param>
/// <param name="right">Rune representing the glyph on the right side of a pair.</param>
/// <returns>Distance adjustment in pixels, scaled to the size specified from
/// <see cref="SeStringDrawParams.FontSize"/>, and rounded.</returns>
internal readonly float CalculateScaledDistance(Rune left, Rune right)
{
// Kerning distance entries are ignored if NUL, U+FFFF(invalid Unicode character), or characters outside
// the basic multilingual plane(BMP) is involved.
if (left.Value is <= 0 or >= char.MaxValue)
return 0;
if (right.Value is <= 0 or >= char.MaxValue)
return 0;
return MathF.Round(
ImGuiNative.ImFont_GetDistanceAdjustmentForPair(
this.Font,
(ushort)left.Value,
(ushort)right.Value) * this.FontSizeScale);
}
/// <summary>Handles style adjusting payloads.</summary>
/// <param name="payload">Payload to handle.</param>
/// <returns><c>true</c> if the payload was handled.</returns>
internal bool HandleStyleAdjustingPayloads(ReadOnlySePayloadSpan payload)
{
switch (payload.MacroCode)
{
case MacroCode.Color:
this.colorStackSet.HandleColorPayload(ref this, payload);
return true;
case MacroCode.EdgeColor:
this.colorStackSet.HandleEdgeColorPayload(ref this, payload);
return true;
case MacroCode.ShadowColor:
this.colorStackSet.HandleShadowColorPayload(ref this, payload);
return true;
case MacroCode.Bold when payload.TryGetExpression(out var e) && e.TryGetUInt(out var u):
// doesn't actually work in chat log
this.Bold = u != 0;
return true;
case MacroCode.Italic when payload.TryGetExpression(out var e) && e.TryGetUInt(out var u):
this.Italic = u != 0;
return true;
case MacroCode.Edge when payload.TryGetExpression(out var e) && e.TryGetUInt(out var u):
this.Edge = u != 0;
return true;
case MacroCode.Shadow when payload.TryGetExpression(out var e) && e.TryGetUInt(out var u):
this.Shadow = u != 0;
return true;
case MacroCode.ColorType:
this.colorStackSet.HandleColorTypePayload(ref this, payload);
return true;
case MacroCode.EdgeColorType:
this.colorStackSet.HandleEdgeColorTypePayload(ref this, payload);
return true;
default:
return false;
}
}
/// <summary>Splits the draw list.</summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
internal readonly void SplitDrawList() =>
ImGuiNative.ImDrawListSplitter_Split(this.splitter, this.drawList, ChannelCount);
/// <summary>Merges the draw list.</summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
internal readonly void MergeDrawList() => ImGuiNative.ImDrawListSplitter_Merge(this.splitter, this.drawList);
}

View file

@ -0,0 +1,48 @@
using System.Numerics;
namespace Dalamud.Interface.ImGuiSeStringRenderer;
/// <summary>Replacement entity to draw instead while rendering a SeString.</summary>
public readonly record struct SeStringReplacementEntity
{
/// <summary>Initializes a new instance of the <see cref="SeStringReplacementEntity"/> struct.</summary>
/// <param name="byteLength">Number of bytes taken by this entity. Must be at least 0. If <c>0</c>, then the entity
/// is considered as empty.</param>
/// <param name="size">Size of this entity in pixels. Components must be non-negative.</param>
/// <param name="draw">Draw callback.</param>
public SeStringReplacementEntity(int byteLength, Vector2 size, DrawDelegate draw)
{
ArgumentOutOfRangeException.ThrowIfNegative(byteLength);
ArgumentOutOfRangeException.ThrowIfNegative(size.X, nameof(size));
ArgumentOutOfRangeException.ThrowIfNegative(size.Y, nameof(size));
ArgumentNullException.ThrowIfNull(draw);
this.ByteLength = byteLength;
this.Size = size;
this.Draw = draw;
}
/// <summary>Gets the replacement entity.</summary>
/// <param name="state">Draw state.</param>
/// <param name="byteOffset">Byte offset in <see cref="SeStringDrawState.Span"/>.</param>
/// <returns>Replacement entity definition, or <c>default</c> if none.</returns>
public delegate SeStringReplacementEntity GetEntityDelegate(scoped in SeStringDrawState state, int byteOffset);
/// <summary>Draws the replacement entity.</summary>
/// <param name="state">Draw state.</param>
/// <param name="byteOffset">Byte offset in <see cref="SeStringDrawState.Span"/>.</param>
/// <param name="offset">Relative offset in pixels w.r.t. <see cref="SeStringDrawState.ScreenOffset"/>.</param>
public delegate void DrawDelegate(scoped in SeStringDrawState state, int byteOffset, Vector2 offset);
/// <summary>Gets the number of bytes taken by this entity.</summary>
public int ByteLength { get; init; }
/// <summary>Gets the size of this entity in pixels.</summary>
public Vector2 Size { get; init; }
/// <summary>Gets the Draw callback.</summary>
public DrawDelegate Draw { get; init; }
/// <summary>Gets a value indicating whether this entity is empty.</summary>
/// <param name="e">Instance of <see cref="SeStringReplacementEntity"/> to test.</param>
public static implicit operator bool(in SeStringReplacementEntity e) => e.ByteLength != 0;
}

View file

@ -1,4 +1,5 @@
using System.Linq;
using System.Numerics;
using System.Text;
using Dalamud.Data;
@ -6,7 +7,9 @@ using Dalamud.Game.Gui;
using Dalamud.Game.Text.SeStringHandling.Payloads;
using Dalamud.Interface.ImGuiSeStringRenderer;
using Dalamud.Interface.ImGuiSeStringRenderer.Internal;
using Dalamud.Interface.Textures.Internal;
using Dalamud.Interface.Utility;
using Dalamud.Storage.Assets;
using Dalamud.Utility;
using ImGuiNET;
@ -32,6 +35,7 @@ internal unsafe class SeStringRendererTestWidget : IDataWindowWidget
private ReadOnlySeString? logkind;
private SeStringDrawParams style;
private bool interactable;
private bool useEntity;
/// <inheritdoc/>
public string DisplayName { get; init; } = "SeStringRenderer Test";
@ -45,12 +49,12 @@ internal unsafe class SeStringRendererTestWidget : IDataWindowWidget
/// <inheritdoc/>
public void Load()
{
this.style = default;
this.style = new() { GetEntity = this.GetEntity };
this.addons = null;
this.uicolor = null;
this.logkind = null;
this.testString = string.Empty;
this.interactable = true;
this.interactable = this.useEntity = true;
this.Ready = true;
}
@ -123,11 +127,15 @@ internal unsafe class SeStringRendererTestWidget : IDataWindowWidget
if (ImGui.Checkbox("Word Wrap", ref t))
this.style.WrapWidth = t ? null : float.PositiveInfinity;
ImGui.SameLine();
t = this.interactable;
if (ImGui.Checkbox("Interactable", ref t))
this.interactable = t;
ImGui.SameLine();
t = this.useEntity;
if (ImGui.Checkbox("Use Entity Replacements", ref t))
this.useEntity = t;
if (ImGui.CollapsingHeader("UIColor Preview"))
{
if (this.uicolor is null)
@ -267,7 +275,22 @@ internal unsafe class SeStringRendererTestWidget : IDataWindowWidget
ImGuiHelpers.ScaledDummy(3);
ImGuiHelpers.CompileSeStringWrapped(
"· For ease of testing, <colortype(506)><edgecolortype(507)>line breaks<colortype(0)><edgecolortype(0)> are automatically replaced to <colortype(502)><edgecolortype(503)>\\<br><colortype(0)><edgecolortype(0)>.",
"Optional features implemented for the following test input:<br>" +
"· <colortype(506)><edgecolortype(507)>line breaks<colortype(0)><edgecolortype(0)> are automatically replaced to <colortype(502)><edgecolortype(503)>\\<br><colortype(0)><edgecolortype(0)>.<br>" +
"· <colortype(506)><edgecolortype(507)>D<link(0xCE)>alamud<colortype(0)><edgecolortype(0)> will display Dalamud.<br>" +
"· <colortype(506)><edgecolortype(507)>W<link(0xCE)>hite<colortype(0)><edgecolortype(0)> will display White.<br>" +
"· <colortype(506)><edgecolortype(507)>D<link(0xCE)>efaultIcon<colortype(0)><edgecolortype(0)> will display DefaultIcon.<br>" +
"· <colortype(506)><edgecolortype(507)>D<link(0xCE)>isabledIcon<colortype(0)><edgecolortype(0)> will display DisabledIcon.<br>" +
"· <colortype(506)><edgecolortype(507)>O<link(0xCE)>utdatedInstallableIcon<colortype(0)><edgecolortype(0)> will display OutdatedInstallableIcon.<br>" +
"· <colortype(506)><edgecolortype(507)>T<link(0xCE)>roubleIcon<colortype(0)><edgecolortype(0)> will display TroubleIcon.<br>" +
"· <colortype(506)><edgecolortype(507)>D<link(0xCE)>evPluginIcon<colortype(0)><edgecolortype(0)> will display DevPluginIcon.<br>" +
"· <colortype(506)><edgecolortype(507)>U<link(0xCE)>pdateIcon<colortype(0)><edgecolortype(0)> will display UpdateIcon.<br>" +
"· <colortype(506)><edgecolortype(507)>I<link(0xCE)>nstalledIcon<colortype(0)><edgecolortype(0)> will display InstalledIcon.<br>" +
"· <colortype(506)><edgecolortype(507)>T<link(0xCE)>hirdIcon<colortype(0)><edgecolortype(0)> will display ThirdIcon.<br>" +
"· <colortype(506)><edgecolortype(507)>T<link(0xCE)>hirdInst<link(0xCE)>alledIcon<colortype(0)><edgecolortype(0)> will display ThirdInstalledIcon.<br>" +
"· <colortype(506)><edgecolortype(507)>C<link(0xCE)>hangelogApiBumpIcon<colortype(0)><edgecolortype(0)> will display ChangelogApiBumpIcon.<br>" +
"· <colortype(506)><edgecolortype(507)>icon<link(0xCE)>(5)<colortype(0)><edgecolortype(0)> will display icon(5). This is different from \\<icon<link(0xCE)>(5)>.<br>" +
"· <colortype(506)><edgecolortype(507)>tex<link(0xCE)>(ui/loadingimage/-nowloading_base25_hr1.tex)<colortype(0)><edgecolortype(0)> will display tex(ui/loadingimage/-nowloading_base25_hr1.tex).",
this.style);
ImGuiHelpers.ScaledDummy(3);
@ -302,10 +325,14 @@ internal unsafe class SeStringRendererTestWidget : IDataWindowWidget
if (this.interactable)
{
if (ImGuiHelpers.CompileSeStringWrapped(this.testString, this.style, new("this is an ImGui id")) is
{ InteractedPayload: { } payload, InteractedPayloadOffset: var offset, InteractedPayloadEnvelope: var envelope } rr)
{
InteractedPayload: { } payload, InteractedPayloadOffset: var offset,
InteractedPayloadEnvelope: var envelope,
Clicked: var clicked
})
{
ImGui.TextUnformatted($"Hovered[{offset}]: {new ReadOnlySeStringSpan(envelope).ToString()}; {payload}");
if (rr.Clicked && payload is DalamudLinkPayload { Plugin: "test" } dlp)
if (clicked && payload is DalamudLinkPayload { Plugin: "test" } dlp)
Util.OpenLink(dlp.ExtraString);
}
}
@ -314,4 +341,138 @@ internal unsafe class SeStringRendererTestWidget : IDataWindowWidget
ImGuiHelpers.CompileSeStringWrapped(this.testString, this.style);
}
}
private SeStringReplacementEntity GetEntity(scoped in SeStringDrawState state, int byteOffset)
{
if (!this.useEntity)
return default;
if (state.Span[byteOffset..].StartsWith("Dalamud"u8))
return new(7, new(state.FontSize, state.FontSize), DrawDalamud);
if (state.Span[byteOffset..].StartsWith("White"u8))
return new(5, new(state.FontSize, state.FontSize), DrawWhite);
if (state.Span[byteOffset..].StartsWith("DefaultIcon"u8))
return new(11, new(state.FontSize, state.FontSize), DrawDefaultIcon);
if (state.Span[byteOffset..].StartsWith("DisabledIcon"u8))
return new(12, new(state.FontSize, state.FontSize), DrawDisabledIcon);
if (state.Span[byteOffset..].StartsWith("OutdatedInstallableIcon"u8))
return new(23, new(state.FontSize, state.FontSize), DrawOutdatedInstallableIcon);
if (state.Span[byteOffset..].StartsWith("TroubleIcon"u8))
return new(11, new(state.FontSize, state.FontSize), DrawTroubleIcon);
if (state.Span[byteOffset..].StartsWith("DevPluginIcon"u8))
return new(13, new(state.FontSize, state.FontSize), DrawDevPluginIcon);
if (state.Span[byteOffset..].StartsWith("UpdateIcon"u8))
return new(10, new(state.FontSize, state.FontSize), DrawUpdateIcon);
if (state.Span[byteOffset..].StartsWith("ThirdIcon"u8))
return new(9, new(state.FontSize, state.FontSize), DrawThirdIcon);
if (state.Span[byteOffset..].StartsWith("ThirdInstalledIcon"u8))
return new(18, new(state.FontSize, state.FontSize), DrawThirdInstalledIcon);
if (state.Span[byteOffset..].StartsWith("ChangelogApiBumpIcon"u8))
return new(20, new(state.FontSize, state.FontSize), DrawChangelogApiBumpIcon);
if (state.Span[byteOffset..].StartsWith("InstalledIcon"u8))
return new(13, new(state.FontSize, state.FontSize), DrawInstalledIcon);
if (state.Span[byteOffset..].StartsWith("tex("u8))
{
var off = state.Span[byteOffset..].IndexOf((byte)')');
var tex = Service<TextureManager>
.Get()
.Shared
.GetFromGame(Encoding.UTF8.GetString(state.Span[(byteOffset + 4)..(byteOffset + off)]))
.GetWrapOrEmpty();
return new(off + 1, tex.Size * (state.FontSize / tex.Size.Y), DrawTexture);
}
if (state.Span[byteOffset..].StartsWith("icon("u8))
{
var off = state.Span[byteOffset..].IndexOf((byte)')');
if (int.TryParse(state.Span[(byteOffset + 5)..(byteOffset + off)], out var parsed))
{
var tex = Service<TextureManager>
.Get()
.Shared
.GetFromGameIcon(parsed)
.GetWrapOrEmpty();
return new(off + 1, tex.Size * (state.FontSize / tex.Size.Y), DrawIcon);
}
}
return default;
static void DrawTexture(scoped in SeStringDrawState state, int byteOffset, Vector2 offset)
{
var off = state.Span[byteOffset..].IndexOf((byte)')');
var tex = Service<TextureManager>
.Get()
.Shared
.GetFromGame(Encoding.UTF8.GetString(state.Span[(byteOffset + 4)..(byteOffset + off)]))
.GetWrapOrEmpty();
state.Draw(
tex.ImGuiHandle,
offset + new Vector2(0, (state.LineHeight - state.FontSize) / 2),
tex.Size * (state.FontSize / tex.Size.Y),
Vector2.Zero,
Vector2.One);
}
static void DrawIcon(scoped in SeStringDrawState state, int byteOffset, Vector2 offset)
{
var off = state.Span[byteOffset..].IndexOf((byte)')');
if (!int.TryParse(state.Span[(byteOffset + 5)..(byteOffset + off)], out var parsed))
return;
var tex = Service<TextureManager>
.Get()
.Shared
.GetFromGameIcon(parsed)
.GetWrapOrEmpty();
state.Draw(
tex.ImGuiHandle,
offset + new Vector2(0, (state.LineHeight - state.FontSize) / 2),
tex.Size * (state.FontSize / tex.Size.Y),
Vector2.Zero,
Vector2.One);
}
static void DrawAsset(scoped in SeStringDrawState state, Vector2 offset, DalamudAsset asset) =>
state.Draw(
Service<DalamudAssetManager>.Get().GetDalamudTextureWrap(asset).ImGuiHandle,
offset + new Vector2(0, (state.LineHeight - state.FontSize) / 2),
new(state.FontSize, state.FontSize),
Vector2.Zero,
Vector2.One);
static void DrawDalamud(scoped in SeStringDrawState state, int byteOffset, Vector2 offset) =>
DrawAsset(state, offset, DalamudAsset.LogoSmall);
static void DrawWhite(scoped in SeStringDrawState state, int byteOffset, Vector2 offset) =>
DrawAsset(state, offset, DalamudAsset.White4X4);
static void DrawDefaultIcon(scoped in SeStringDrawState state, int byteOffset, Vector2 offset) =>
DrawAsset(state, offset, DalamudAsset.DefaultIcon);
static void DrawDisabledIcon(scoped in SeStringDrawState state, int byteOffset, Vector2 offset) =>
DrawAsset(state, offset, DalamudAsset.DisabledIcon);
static void DrawOutdatedInstallableIcon(scoped in SeStringDrawState state, int byteOffset, Vector2 offset) =>
DrawAsset(state, offset, DalamudAsset.OutdatedInstallableIcon);
static void DrawTroubleIcon(scoped in SeStringDrawState state, int byteOffset, Vector2 offset) =>
DrawAsset(state, offset, DalamudAsset.TroubleIcon);
static void DrawDevPluginIcon(scoped in SeStringDrawState state, int byteOffset, Vector2 offset) =>
DrawAsset(state, offset, DalamudAsset.DevPluginIcon);
static void DrawUpdateIcon(scoped in SeStringDrawState state, int byteOffset, Vector2 offset) =>
DrawAsset(state, offset, DalamudAsset.UpdateIcon);
static void DrawInstalledIcon(scoped in SeStringDrawState state, int byteOffset, Vector2 offset) =>
DrawAsset(state, offset, DalamudAsset.InstalledIcon);
static void DrawThirdIcon(scoped in SeStringDrawState state, int byteOffset, Vector2 offset) =>
DrawAsset(state, offset, DalamudAsset.ThirdIcon);
static void DrawThirdInstalledIcon(scoped in SeStringDrawState state, int byteOffset, Vector2 offset) =>
DrawAsset(state, offset, DalamudAsset.ThirdInstalledIcon);
static void DrawChangelogApiBumpIcon(scoped in SeStringDrawState state, int byteOffset, Vector2 offset) =>
DrawAsset(state, offset, DalamudAsset.ChangelogApiBumpIcon);
}
}