From 878b96e67dd0264b737c95d8d4e077ae45ab284b Mon Sep 17 00:00:00 2001 From: srkizer Date: Sun, 4 Aug 2024 20:30:49 +0900 Subject: [PATCH] SeString renderer: Implement replacement entity (#1993) * Refactor * Implement replacement entity * Apply rounding functions more correctly --- Dalamud/Interface/ColorHelpers.cs | 9 + .../ImGuiSeStringRenderer/Internal/GfdFile.cs | 11 + .../Internal/SeStringColorStackSet.cs | 198 ++++ .../Internal/SeStringRenderer.cs | 872 ++++++------------ .../TextProcessing/LineBreakEnumerator.cs | 30 +- .../SeStringDrawChannel.cs | 42 + .../SeStringDrawParams.cs | 90 +- .../SeStringDrawState.cs | 400 ++++++++ .../SeStringReplacementEntity.cs | 48 + .../Widgets/SeStringRendererTestWidget.cs | 177 +++- 10 files changed, 1202 insertions(+), 675 deletions(-) create mode 100644 Dalamud/Interface/ImGuiSeStringRenderer/Internal/SeStringColorStackSet.cs create mode 100644 Dalamud/Interface/ImGuiSeStringRenderer/SeStringDrawChannel.cs create mode 100644 Dalamud/Interface/ImGuiSeStringRenderer/SeStringDrawState.cs create mode 100644 Dalamud/Interface/ImGuiSeStringRenderer/SeStringReplacementEntity.cs diff --git a/Dalamud/Interface/ColorHelpers.cs b/Dalamud/Interface/ColorHelpers.cs index 318805529b..e99d80cd81 100644 --- a/Dalamud/Interface/ColorHelpers.cs +++ b/Dalamud/Interface/ColorHelpers.cs @@ -2,6 +2,7 @@ using System.Diagnostics.CodeAnalysis; using System.Drawing; using System.Numerics; +using System.Runtime.CompilerServices; namespace Dalamud.Interface; @@ -247,6 +248,14 @@ public static Vector4 Desaturate(this Vector4 color, float amount) public static uint Desaturate(uint color, float amount) => RgbaVector4ToUint(Desaturate(RgbaUintToVector4(color), amount)); + /// Applies the given opacity value ranging from 0 to 1 to an uint value containing a RGBA value. + /// RGBA value to transform. + /// Opacity to apply. + /// Transformed value. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static uint ApplyOpacity(uint rgba, float opacity) => + ((uint)MathF.Round((rgba >> 24) * opacity) << 24) | (rgba & 0xFFFFFFu); + /// /// Fade a color. /// diff --git a/Dalamud/Interface/ImGuiSeStringRenderer/Internal/GfdFile.cs b/Dalamud/Interface/ImGuiSeStringRenderer/Internal/GfdFile.cs index 194d719570..8559cabdf2 100644 --- a/Dalamud/Interface/ImGuiSeStringRenderer/Internal/GfdFile.cs +++ b/Dalamud/Interface/ImGuiSeStringRenderer/Internal/GfdFile.cs @@ -145,5 +145,16 @@ public Vector2 Size /// Gets the UV1 of the HQ version of this entry. public Vector2 HqUv1 => new((this.Left + this.Width) / 256f, (this.Top + this.Height + 170.5f) / 512f); + + /// Calculates the size in pixels of a GFD entry when drawn along with a text. + /// Font size in pixels. + /// Whether to draw the HQ texture. + /// Determined size of the GFD entry when drawn. + 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); + } } } diff --git a/Dalamud/Interface/ImGuiSeStringRenderer/Internal/SeStringColorStackSet.cs b/Dalamud/Interface/ImGuiSeStringRenderer/Internal/SeStringColorStackSet.cs new file mode 100644 index 0000000000..6d7b0a21a1 --- /dev/null +++ b/Dalamud/Interface/ImGuiSeStringRenderer/Internal/SeStringColorStackSet.cs @@ -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; + +/// Color stacks to use while evaluating a SeString. +internal sealed class SeStringColorStackSet +{ + /// Parsed , containing colors to use with + /// . + private readonly uint[] colorTypes; + + /// Parsed , containing colors to use with + /// . + private readonly uint[] edgeColorTypes; + + /// Foreground color stack while evaluating a SeString for rendering. + /// Touched only from the main thread. + private readonly List colorStack = []; + + /// Edge/border color stack while evaluating a SeString for rendering. + /// Touched only from the main thread. + private readonly List edgeColorStack = []; + + /// Shadow color stack while evaluating a SeString for rendering. + /// Touched only from the main thread. + private readonly List shadowColorStack = []; + + /// Initializes a new instance of the class. + /// UIColor sheet. + public SeStringColorStackSet(ExcelSheet 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); + } + } + + /// Gets a value indicating whether at least one color has been pushed to the edge color stack. + public bool HasAdditionalEdgeColor { get; private set; } + + /// Resets the colors in the stack. + /// Draw state. + 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); + } + + /// Handles a payload. + /// Draw state. + /// Payload to handle. + internal void HandleColorPayload(scoped ref SeStringDrawState drawState, ReadOnlySePayloadSpan payload) => + drawState.Color = ColorHelpers.ApplyOpacity(AdjustStack(this.colorStack, payload), drawState.Opacity); + + /// Handles a payload. + /// Draw state. + /// Payload to handle. + 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; + } + + /// Handles a payload. + /// Draw state. + /// Payload to handle. + internal void HandleShadowColorPayload( + scoped ref SeStringDrawState drawState, + ReadOnlySePayloadSpan payload) => + drawState.ShadowColor = ColorHelpers.ApplyOpacity(AdjustStack(this.shadowColorStack, payload), drawState.Opacity); + + /// Handles a payload. + /// Draw state. + /// Payload to handle. + internal void HandleColorTypePayload( + scoped ref SeStringDrawState drawState, + ReadOnlySePayloadSpan payload) => + drawState.Color = ColorHelpers.ApplyOpacity(AdjustStack(this.colorStack, this.colorTypes, payload), drawState.Opacity); + + /// Handles a payload. + /// Draw state. + /// Payload to handle. + 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; + } + + /// Swaps red and blue channels of a given color in ARGB(BB GG RR AA) and ABGR(RR GG BB AA). + /// Color to process. + /// Swapped color. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static uint SwapRedBlue(uint x) => (x & 0xFF00FF00u) | ((x >> 16) & 0xFF) | ((x & 0xFF) << 16); + + private static unsafe uint AdjustStack(List 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 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]; + } +} diff --git a/Dalamud/Interface/ImGuiSeStringRenderer/Internal/SeStringRenderer.cs b/Dalamud/Interface/ImGuiSeStringRenderer/Internal/SeStringRenderer.cs index b942ef8447..23b672a3b5 100644 --- a/Dalamud/Interface/ImGuiSeStringRenderer/Internal/SeStringRenderer.cs +++ b/Dalamud/Interface/ImGuiSeStringRenderer/Internal/SeStringRenderer.cs @@ -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 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 @@ private readonly delegate* unmanaged< /// Parsed gfdata.gfd file, containing bitmap font icon lookup table. private readonly GfdFile gfd; - /// Parsed , containing colors to use with - /// . - private readonly uint[] colorTypes; - - /// Parsed , containing colors to use with - /// . - private readonly uint[] edgeColorTypes; - /// Parsed text fragments from a SeString. /// Touched only from the main thread. private readonly List fragments = []; - /// Foreground color stack while evaluating a SeString for rendering. + /// Color stacks to use while evaluating a SeString for rendering. /// Touched only from the main thread. - private readonly List colorStack = []; - - /// Edge/border color stack while evaluating a SeString for rendering. - /// Touched only from the main thread. - private readonly List edgeColorStack = []; - - /// Shadow color stack while evaluating a SeString for rendering. - /// Touched only from the main thread. - private readonly List shadowColorStack = []; + private readonly SeStringColorStackSet colorStackSet; /// Splits a draw list so that different layers of a single glyph can be drawn out of order. private ImDrawListSplitter* splitter = ImGuiNative.ImDrawListSplitter_ImDrawListSplitter(); @@ -113,29 +87,8 @@ private readonly delegate* unmanaged< [ServiceManager.ServiceConstructor] private SeStringRenderer(DataManager dm, TargetSigScanner sigScanner) { - var uiColor = dm.Excel.GetSheet()!; - 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() ?? throw new InvalidOperationException("Failed to access UIColor sheet.")); this.gfd = dm.GetFile("common/font/gfdata.gfd")!; // SetUnhandledExceptionFilter(who cares); @@ -266,19 +219,11 @@ public SeStringDrawResult Draw( 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,20 +237,23 @@ public SeStringDrawResult Draw( // 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]; - this.DrawTextFragment(ref state, f.Offset, f.IsSoftHyphenVisible, data, lastRune, f.Link); + 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 @@ public SeStringDrawResult Draw( 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 @@ public SeStringDrawResult Draw( // 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.Empty : sss.Data[hoveredLinkOffset..]).GetEnumerator(); @@ -412,18 +358,6 @@ or UnicodeLineBreakClass.LF return displayRune.Value != 0; } - /// Swaps red and blue channels of a given color in ARGB(BB GG RR AA) and ABGR(RR GG BB AA). - /// Color to process. - /// Swapped color. - private static uint SwapRedBlue(uint x) => (x & 0xFF00FF00u) | ((x >> 16) & 0xFF) | ((x & 0xFF) << 16); - - /// Applies the given opacity value ranging from 0 to 1 to an uint value containing a RGBA value. - /// RGBA value to transform. - /// Opacity to apply. - /// Transformed value. - 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,66 +371,133 @@ private void ReleaseUnmanagedResources() /// Draw state. /// Y offset adjustment for all text fragments. Used to honor /// . - 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 = - p.Payload.TryGetExpression(out var e) && - e.TryGetUInt(out var u) && - u == (uint)LinkMacroPayloadType.Terminator - ? -1 - : prev + p.Offset; - - // Split only if we're not splitting at the beginning. - if (p.Offset != 0) + 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: { - curr = prev + p.Offset; + nextLink = + p.Payload.TryGetExpression(out var e) && + e.TryGetUInt(out var u) && + u == (uint)LinkMacroPayloadType.Terminator + ? -1 + : prev + p.Offset; + + // Split only if we're not splitting at the beginning. + if (p.Offset != 0) + { + curr = prev + p.Offset; + break2 = true; + break; + } + + link = nextLink; + break; } - linkOffset = nextLinkOffset; + 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) { - // 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; - 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; + // 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.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.WrapWidth; + } } if (overflows) { - // 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); + // 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.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 @@ private void CreateTextFragments(ref DrawState state, float baseY) 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 @@ private void CreateTextFragments(ref DrawState state, float baseY) if (fragment.BreakAfter) { xy.X = w = 0; - xy.Y += state.Params.LineHeight; + xy.Y += state.LineHeight; } } } @@ -537,9 +538,9 @@ private void CreateTextFragments(ref DrawState state, float baseY) /// Byte span of the SeString fragment to draw. /// Rune that preceded this text fragment in the same line, or 0 if none. /// Byte offset of the link payload that decorates this text fragment in - /// , or -1 if none. + /// , or -1 if none. private void DrawTextFragment( - ref DrawState state, + ref SeStringDrawState state, Vector2 offset, bool displaySoftHyphen, ReadOnlySpan span, @@ -559,186 +560,47 @@ private void DrawTextFragment( 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) - continue; - - var size = state.CalculateGfdEntrySize(gfdEntry, out var useHq); - state.SetCurrentChannel(ChannelFore); - state.Draw( - offset + new Vector2(x, MathF.Round((state.Params.LineHeight - size.Y) / 2)), - gfdTextureSrv, - Vector2.Zero, - size, - Vector2.Zero, - useHq ? gfdEntry.HqUv0 : gfdEntry.Uv0, - useHq ? gfdEntry.HqUv1 : gfdEntry.Uv1, - ApplyOpacityValue(uint.MaxValue, state.Params.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; - } + if (state.HandleStyleAdjustingPayloads(enu.Current.Payload)) + continue; - default: - continue; + 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( + gfdTextureSrv, + offset + new Vector2(x, MathF.Round((state.LineHeight - size.Y) / 2)), + size, + useHq ? gfdEntry.HqUv0 : gfdEntry.Uv0, + useHq ? gfdEntry.HqUv1 : gfdEntry.Uv1, + 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; } 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 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 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,11 +693,138 @@ when payload.TryGetExpression(out var icon) && icon.TryGetInt(out var iconId): return None; } + /// Creates a text fragment. + /// Draw state. + /// Starting byte offset (inclusive) in that this fragment + /// deals with. + /// Ending byte offset (exclusive) in that this fragment deals + /// with. + /// Whether to break line after this fragment. + /// Offset in pixels w.r.t. . + /// Byte offset of the link payload in that + /// decorates this text fragment. + /// Entity to display in place of this fragment. + /// 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. + /// Newly created text fragment. + private TextFragment CreateFragment( + scoped in SeStringDrawState state, + int from, + int to, + bool breakAfter, + Vector2 offset, + 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; + var advanceWidth = 0f; + var advanceWidthWithoutSoftHyphen = 0f; + var firstDisplayRune = default(Rune?); + var lastDisplayRune = default(Rune); + var lastNonSoftHyphenRune = default(Rune); + var endsWithSoftHyphen = false; + 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 } && + 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 = gfdEntry.CalculateScaledSize(state.FontSize, out _); + w = Math.Max(w, x + size.X); + x += MathF.Round(size.X); + displayRune = default; + } + else if (TryGetDisplayRune(effectiveRune, out displayRune)) + { + // This is a printable character, or a standard whitespace character. + 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) && + UnicodeData.LineBreak[displayRune.Value] is not UnicodeLineBreakClass.GL; + } + else + { + continue; + } + + if (isBreakableWhitespace) + { + advanceWidth = x; + } + else + { + if (firstDisplayRune is not null && w > wrapWidth && effectiveRune.Value != SoftHyphen) + { + to = byteOffset; + break; + } + + advanceWidth = x; + visibleWidth = w; + } + + firstDisplayRune ??= displayRune; + lastDisplayRune = displayRune; + endsWithSoftHyphen = effectiveRune.Value == SoftHyphen; + if (!endsWithSoftHyphen) + { + advanceWidthWithoutSoftHyphen = x; + lastNonSoftHyphenRune = displayRune; + } + } + + return new( + from, + to, + link, + offset, + entity, + visibleWidth, + advanceWidth, + advanceWidthWithoutSoftHyphen, + breakAfter, + endsWithSoftHyphen, + firstDisplayRune ?? default, + lastNonSoftHyphenRune); + } + /// Represents a text fragment in a SeString span. /// Starting byte offset (inclusive) in a SeString. /// Ending byte offset (exclusive) in a SeString. /// Byte offset of the link that decorates this text fragment, or -1 if none. /// Offset in pixels w.r.t. . + /// Replacement entity, if any. /// Visible width of this text fragment. This is the width required to draw everything /// without clipping. /// Advance width of this text fragment. This is the width required to add to the cursor @@ -852,6 +841,7 @@ private record struct TextFragment( int To, int Link, Vector2 Offset, + SeStringReplacementEntity Entity, float VisibleWidth, float AdvanceWidth, float AdvanceWidthWithoutSoftHyphen, @@ -862,292 +852,4 @@ private record struct TextFragment( { public bool IsSoftHyphenVisible => this.EndsWithSoftHyphen && this.BreakAfter; } - - /// Represents a temporary state required for drawing. - private ref struct DrawState( - ReadOnlySeStringSpan raw, - SeStringDrawParams.Resolved @params, - ImDrawListSplitter* splitter) - { - /// Raw SeString span. - public readonly ReadOnlySeStringSpan Raw = raw; - - /// Multiplier value for glyph metrics, so that it scales to . - /// - public readonly float FontSizeScale = @params.FontSize / @params.Font->FontSize; - - /// Value obtained from . - public readonly Vector2 ScreenOffset = @params.ScreenOffset; - - /// Splitter to split . - public readonly ImDrawListSplitter* Splitter = splitter; - - /// Resolved draw parameters from the caller. - public SeStringDrawParams.Resolved Params = @params; - - /// Calculates the size in pixels of a GFD entry when drawn. - /// GFD entry to determine the size. - /// Whether to draw the HQ texture. - /// Determined size of the GFD entry when drawn. - 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); - } - - /// Sets the current channel in the ImGui draw list splitter. - /// Channel to switch to. - public readonly void SetCurrentChannel(int channelIndex) => - ImGuiNative.ImDrawListSplitter_SetCurrentChannel( - this.Splitter, - this.Params.DrawList, - channelIndex); - - /// Draws a single glyph. - /// Offset of the glyph in pixels w.r.t. - /// . - /// Glyph to draw. - /// Transformation for that will push top and bottom pixels to - /// apply faux italicization. - /// Color of the glyph. - 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(g.TextureIndex).TexID, - g.XY0 * this.FontSizeScale, - g.XY1 * this.FontSizeScale, - dyItalic * this.FontSizeScale, - g.UV0, - g.UV1, - color); - - /// Draws a single glyph. - /// Offset of the glyph in pixels w.r.t. - /// . - /// ImGui texture ID to draw from. - /// Left top corner of the glyph w.r.t. its glyph origin in the target draw list. - /// Right bottom corner of the glyph w.r.t. its glyph origin in the target draw list. - /// Transformation for and that will push - /// top and bottom pixels to apply faux italicization. - /// Left top corner of the glyph w.r.t. its glyph origin in the source texture. - /// Right bottom corner of the glyph w.r.t. its glyph origin in the source texture. - /// Color of the glyph. - 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); - } - - /// Draws an underline, for links. - /// Offset of the glyph in pixels w.r.t. - /// . - /// Advance width of the glyph. - 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); - } - } - - /// Creates a text fragment. - /// Associated renderer. - /// Starting byte offset (inclusive) in that this fragment deals with. - /// - /// Ending byte offset (exclusive) in that this fragment deals with. - /// Whether to break line after this fragment. - /// Offset in pixels w.r.t. . - /// Byte offset of the link payload in that decorates this - /// text fragment. - /// 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. - /// Newly created text fragment. - public readonly TextFragment CreateFragment( - SeStringRenderer renderer, - int from, - int to, - bool breakAfter, - Vector2 offset, - int activeLinkOffset, - float wrapWidth = float.MaxValue) - { - var x = 0f; - var w = 0f; - var visibleWidth = 0f; - var advanceWidth = 0f; - var advanceWidthWithoutSoftHyphen = 0f; - var firstDisplayRune = default(Rune?); - var lastDisplayRune = default(Rune); - var lastNonSoftHyphenRune = default(Rune); - var endsWithSoftHyphen = false; - foreach (var c in UtfEnumerator.From(this.Raw.Data[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) && - !gfdEntry.IsEmpty) - { - // This is an icon payload. - var size = this.CalculateGfdEntrySize(gfdEntry, out _); - w = Math.Max(w, x + size.X); - x += MathF.Round(size.X); - displayRune = default; - } - 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); - - isBreakableWhitespace = - Rune.IsWhiteSpace(displayRune) && - UnicodeData.LineBreak[displayRune.Value] is not UnicodeLineBreakClass.GL; - } - else - { - continue; - } - - if (isBreakableWhitespace) - { - advanceWidth = x; - } - else - { - if (firstDisplayRune is not null && w > wrapWidth && effectiveRune.Value != SoftHyphen) - { - to = byteOffset; - break; - } - - advanceWidth = x; - visibleWidth = w; - } - - firstDisplayRune ??= displayRune; - lastDisplayRune = displayRune; - endsWithSoftHyphen = effectiveRune.Value == SoftHyphen; - if (!endsWithSoftHyphen) - { - advanceWidthWithoutSoftHyphen = x; - lastNonSoftHyphenRune = displayRune; - } - } - - return new( - from, - to, - activeLinkOffset, - offset, - visibleWidth, - advanceWidth, - advanceWidthWithoutSoftHyphen, - breakAfter, - endsWithSoftHyphen, - firstDisplayRune ?? default, - lastNonSoftHyphenRune); - } - - /// Gets the glyph corresponding to the given codepoint. - /// An instance of that represents a character to display. - /// Corresponding glyph, or glyph of a fallback character specified from - /// . - public readonly ref ImGuiHelpers.ImFontGlyphReal FindGlyph(Rune rune) - { - 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; - } - - /// Gets the glyph corresponding to the given codepoint. - /// An instance of 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. - /// Corresponding glyph, or glyph of a fallback character specified from - /// . - 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; - } - - /// Gets the kerning adjustment between two glyphs in a succession corresponding to the given runes. - /// - /// Rune representing the glyph on the left side of a pair. - /// Rune representing the glyph on the right side of a pair. - /// Distance adjustment in pixels, scaled to the size specified from - /// , and rounded. - 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); - } - } } diff --git a/Dalamud/Interface/ImGuiSeStringRenderer/Internal/TextProcessing/LineBreakEnumerator.cs b/Dalamud/Interface/ImGuiSeStringRenderer/Internal/TextProcessing/LineBreakEnumerator.cs index 9113ef7038..fa994bcd25 100644 --- a/Dalamud/Interface/ImGuiSeStringRenderer/Internal/TextProcessing/LineBreakEnumerator.cs +++ b/Dalamud/Interface/ImGuiSeStringRenderer/Internal/TextProcessing/LineBreakEnumerator.cs @@ -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; - /// Initializes a new instance of the struct. /// UTF-N byte sequence. /// Flags to pass to sub-enumerator. @@ -58,11 +58,25 @@ private enum LineBreakMode : byte /// public (int ByteOffset, bool Mandatory) Current { get; private set; } + /// Gets a value indicating whether the end of the underlying span has been reached. + public bool Finished { get; private set; } + + /// Resumes enumeration with the given data. + /// The data. + /// Offset to add to .ByteOffset. + public void ResumeWith(ReadOnlySpan data, int offsetDelta) + { + this.enumerator = UtfEnumerator.From(data, this.enumeratorFlags); + this.dataLength = data.Length; + this.currentByteOffsetDelta = offsetDelta; + this.Finished = false; + } + /// [SuppressMessage("ReSharper", "ConvertIfStatementToSwitchStatement", Justification = "No")] public bool MoveNext() { - if (this.finished) + if (this.Finished) return false; while (this.enumerator.MoveNext()) @@ -77,10 +91,10 @@ public bool MoveNext() 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 @@ public bool MoveNext() // 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; } diff --git a/Dalamud/Interface/ImGuiSeStringRenderer/SeStringDrawChannel.cs b/Dalamud/Interface/ImGuiSeStringRenderer/SeStringDrawChannel.cs new file mode 100644 index 0000000000..d34a9ee5b5 --- /dev/null +++ b/Dalamud/Interface/ImGuiSeStringRenderer/SeStringDrawChannel.cs @@ -0,0 +1,42 @@ +namespace Dalamud.Interface.ImGuiSeStringRenderer; + +/// Predefined channels for drawing onto, for out-of-order drawing. +// Notes: values must be consecutively increasing, starting from 0. Higher values has higher priority. +public enum SeStringDrawChannel +{ + /// Next draw operation on the draw list will be put below . + BelowBackground, + + /// Next draw operation on the draw list will be put onto the background channel. + Background, + + /// Next draw operation on the draw list will be put above . + AboveBackground, + + /// Next draw operation on the draw list will be put below . + BelowShadow, + + /// Next draw operation on the draw list will be put onto the shadow channel. + Shadow, + + /// Next draw operation on the draw list will be put above . + AboveShadow, + + /// Next draw operation on the draw list will be put below . + BelowEdge, + + /// Next draw operation on the draw list will be put onto the edge channel. + Edge, + + /// Next draw operation on the draw list will be put above . + AboveEdge, + + /// Next draw operation on the draw list will be put below . + BelowForeground, + + /// Next draw operation on the draw list will be put onto the foreground channel. + Foreground, + + /// Next draw operation on the draw list will be put above . + AboveForeground, +} diff --git a/Dalamud/Interface/ImGuiSeStringRenderer/SeStringDrawParams.cs b/Dalamud/Interface/ImGuiSeStringRenderer/SeStringDrawParams.cs index 543f4c07a1..cdd5e1db6d 100644 --- a/Dalamud/Interface/ImGuiSeStringRenderer/SeStringDrawParams.cs +++ b/Dalamud/Interface/ImGuiSeStringRenderer/SeStringDrawParams.cs @@ -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 /// public ImDrawListPtr? TargetDrawList { get; set; } - /// Gets or sets the font to use. - /// Font to use, or null to use (the default). - public ImFontPtr? Font { get; set; } + /// 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. + public SeStringReplacementEntity.GetEntityDelegate? GetEntity { get; set; } /// Gets or sets the screen offset of the left top corner. /// Screen offset to draw at, or null to use . public Vector2? ScreenOffset { get; set; } + /// Gets or sets the font to use. + /// Font to use, or null to use (the default). + public ImFontPtr? Font { get; set; } + /// Gets or sets the font size. /// Font size in pixels, or 0 to use the current ImGui font size . /// @@ -86,83 +88,23 @@ public record struct SeStringDrawParams public bool Italic { get; set; } /// Gets or sets a value indicating whether the text is rendered with edge. + /// If an edge color is pushed with or + /// , it will be drawn regardless. Set to + /// true and set to 0 to fully disable edge. public bool Edge { get; set; } /// Gets or sets a value indicating whether the text is rendered with shadow. public bool Shadow { get; set; } - private readonly unsafe ImFont* EffectiveFont => + /// Gets the effective font. + 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); - - private readonly float EffectiveOpacity => this.Opacity ?? ImGui.GetStyle().Alpha; - - /// Calculated values from using ImGui styles. - [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) - { - /// - public readonly ImDrawList* DrawList = ssdp.TargetDrawList ?? ImGui.GetWindowDrawList(); - - /// - public readonly ImFont* Font = ssdp.EffectiveFont; - - /// - public readonly Vector2 ScreenOffset = ssdp.ScreenOffset ?? ImGui.GetCursorScreenPos(); - - /// - public readonly float FontSize = ssdp.FontSize ?? ImGui.GetFontSize(); - - /// - public readonly float LineHeight = MathF.Round(ssdp.EffectiveLineHeight); - - /// - public readonly float WrapWidth = ssdp.WrapWidth ?? ImGui.GetContentRegionAvail().X; - - /// - public readonly float LinkUnderlineThickness = ssdp.LinkUnderlineThickness ?? 0f; - - /// - public readonly float Opacity = ssdp.EffectiveOpacity; - - /// - public readonly float EdgeOpacity = (ssdp.EdgeStrength ?? 0.25f) * ssdp.EffectiveOpacity; - - /// - public uint Color = ssdp.Color ?? ImGui.GetColorU32(ImGuiCol.Text); - - /// - public uint EdgeColor = ssdp.EdgeColor ?? 0xFF000000; - - /// - public uint ShadowColor = ssdp.ShadowColor ?? 0xFF000000; - - /// - public readonly uint LinkHoverBackColor = ssdp.LinkHoverBackColor ?? ImGui.GetColorU32(ImGuiCol.ButtonHovered); - - /// - public readonly uint LinkActiveBackColor = ssdp.LinkActiveBackColor ?? ImGui.GetColorU32(ImGuiCol.ButtonActive); - - /// - public readonly bool ForceEdgeColor = ssdp.ForceEdgeColor; - - /// - public bool Bold = ssdp.Bold; - - /// - public bool Italic = ssdp.Italic; - - /// - public bool Edge = ssdp.Edge; + /// Gets the effective line height in pixels. + internal readonly float EffectiveLineHeight => (this.FontSize ?? ImGui.GetFontSize()) * (this.LineHeight ?? 1f); - /// - public bool Shadow = ssdp.Shadow; - } + /// Gets the effective opacity. + internal readonly float EffectiveOpacity => this.Opacity ?? ImGui.GetStyle().Alpha; } diff --git a/Dalamud/Interface/ImGuiSeStringRenderer/SeStringDrawState.cs b/Dalamud/Interface/ImGuiSeStringRenderer/SeStringDrawState.cs new file mode 100644 index 0000000000..d8348e5f26 --- /dev/null +++ b/Dalamud/Interface/ImGuiSeStringRenderer/SeStringDrawState.cs @@ -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; + +/// Calculated values from using ImGui styles. +[StructLayout(LayoutKind.Sequential)] +public unsafe ref struct SeStringDrawState +{ + private static readonly int ChannelCount = Enum.GetValues().Length; + + private readonly ImDrawList* drawList; + private readonly SeStringColorStackSet colorStackSet; + private readonly ImDrawListSplitter* splitter; + + /// Initializes a new instance of the struct. + /// Raw SeString byte span. + /// Instance of to initialize from. + /// Instance of to use. + /// Instance of ImGui Splitter to use. + internal SeStringDrawState( + ReadOnlySpan 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; + } + + /// + public readonly ImDrawListPtr DrawList => new(this.drawList); + + /// Gets the raw SeString byte span. + public ReadOnlySpan Span { get; } + + /// + public SeStringReplacementEntity.GetEntityDelegate? GetEntity { get; } + + /// + public Vector2 ScreenOffset { get; } + + /// + public ImFont* Font { get; } + + /// + public float FontSize { get; } + + /// Gets the multiplier value for glyph metrics, so that it scales to . + /// Multiplied to , + /// , and distance values from + /// . + public float FontSizeScale { get; } + + /// + public float LineHeight { get; } + + /// + public float WrapWidth { get; } + + /// + public float LinkUnderlineThickness { get; } + + /// + public float Opacity { get; } + + /// + public float EdgeOpacity { get; } + + /// + public uint Color { get; set; } + + /// + public uint EdgeColor { get; set; } + + /// + public uint ShadowColor { get; set; } + + /// + public uint LinkHoverBackColor { get; } + + /// + public uint LinkActiveBackColor { get; } + + /// + public bool ForceEdgeColor { get; } + + /// + public bool Bold { get; set; } + + /// + public bool Italic { get; set; } + + /// + public bool Edge { get; set; } + + /// + public bool Shadow { get; set; } + + /// Gets a value indicating whether the edge should be drawn. + public readonly bool ShouldDrawEdge => + (this.Edge || this.colorStackSet.HasAdditionalEdgeColor) && this.EdgeColor >= 0x1000000; + + /// Gets a value indicating whether the edge should be drawn. + public readonly bool ShouldDrawShadow => this is { Shadow: true, ShadowColor: >= 0x1000000 }; + + /// Gets a value indicating whether the edge should be drawn. + public readonly bool ShouldDrawForeground => this is { Color: >= 0x1000000 }; + + /// Sets the current channel in the ImGui draw list splitter. + /// Channel to switch to. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public readonly void SetCurrentChannel(SeStringDrawChannel channelIndex) => + ImGuiNative.ImDrawListSplitter_SetCurrentChannel(this.splitter, this.drawList, (int)channelIndex); + + /// Draws a single texture. + /// ImGui texture ID to draw from. + /// Offset of the glyph in pixels w.r.t. . + /// Right bottom corner of the glyph w.r.t. its glyph origin in the target draw list. + /// Left top corner of the glyph w.r.t. its glyph origin in the source texture. + /// Right bottom corner of the glyph w.r.t. its glyph origin in the source texture. + /// Color of the glyph in RGBA. + 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); + } + + /// Draws a single texture. + /// ImGui texture ID to draw from. + /// Offset of the glyph in pixels w.r.t. . + /// Left top corner of the glyph w.r.t. its glyph origin in the target draw list. + /// Right bottom corner of the glyph w.r.t. its glyph origin in the target draw list. + /// Left top corner of the glyph w.r.t. its glyph origin in the source texture. + /// Right bottom corner of the glyph w.r.t. its glyph origin in the source texture. + /// Color of the glyph in RGBA. + /// Transformation for and that will push + /// top and bottom pixels to apply faux italicization by and + /// respectively. + 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); + } + + /// Draws a single glyph using current styling configurations. + /// Glyph to draw. + /// Offset of the glyph in pixels w.r.t. . + internal readonly void DrawGlyph(scoped in ImGuiHelpers.ImFontGlyphReal g, Vector2 offset) + { + var texId = this.Font->ContainerAtlas->Textures.Ref(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); + } + } + + /// Draws an underline, for links. + /// Offset of the glyph in pixels w.r.t. + /// . + /// Advance width of the glyph. + 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); + } + } + + /// Gets the glyph corresponding to the given codepoint. + /// An instance of that represents a character to display. + /// Corresponding glyph, or glyph of a fallback character specified from + /// . + 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; + } + + /// Gets the glyph corresponding to the given codepoint. + /// An instance of 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. + /// Corresponding glyph, or glyph of a fallback character specified from + /// . + 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; + } + + /// Gets the kerning adjustment between two glyphs in a succession corresponding to the given runes. + /// + /// Rune representing the glyph on the left side of a pair. + /// Rune representing the glyph on the right side of a pair. + /// Distance adjustment in pixels, scaled to the size specified from + /// , and rounded. + 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); + } + + /// Handles style adjusting payloads. + /// Payload to handle. + /// true if the payload was handled. + 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; + } + } + + /// Splits the draw list. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal readonly void SplitDrawList() => + ImGuiNative.ImDrawListSplitter_Split(this.splitter, this.drawList, ChannelCount); + + /// Merges the draw list. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal readonly void MergeDrawList() => ImGuiNative.ImDrawListSplitter_Merge(this.splitter, this.drawList); +} diff --git a/Dalamud/Interface/ImGuiSeStringRenderer/SeStringReplacementEntity.cs b/Dalamud/Interface/ImGuiSeStringRenderer/SeStringReplacementEntity.cs new file mode 100644 index 0000000000..b14e12073e --- /dev/null +++ b/Dalamud/Interface/ImGuiSeStringRenderer/SeStringReplacementEntity.cs @@ -0,0 +1,48 @@ +using System.Numerics; + +namespace Dalamud.Interface.ImGuiSeStringRenderer; + +/// Replacement entity to draw instead while rendering a SeString. +public readonly record struct SeStringReplacementEntity +{ + /// Initializes a new instance of the struct. + /// Number of bytes taken by this entity. Must be at least 0. If 0, then the entity + /// is considered as empty. + /// Size of this entity in pixels. Components must be non-negative. + /// Draw callback. + 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; + } + + /// Gets the replacement entity. + /// Draw state. + /// Byte offset in . + /// Replacement entity definition, or default if none. + public delegate SeStringReplacementEntity GetEntityDelegate(scoped in SeStringDrawState state, int byteOffset); + + /// Draws the replacement entity. + /// Draw state. + /// Byte offset in . + /// Relative offset in pixels w.r.t. . + public delegate void DrawDelegate(scoped in SeStringDrawState state, int byteOffset, Vector2 offset); + + /// Gets the number of bytes taken by this entity. + public int ByteLength { get; init; } + + /// Gets the size of this entity in pixels. + public Vector2 Size { get; init; } + + /// Gets the Draw callback. + public DrawDelegate Draw { get; init; } + + /// Gets a value indicating whether this entity is empty. + /// Instance of to test. + public static implicit operator bool(in SeStringReplacementEntity e) => e.ByteLength != 0; +} diff --git a/Dalamud/Interface/Internal/Windows/Data/Widgets/SeStringRendererTestWidget.cs b/Dalamud/Interface/Internal/Windows/Data/Widgets/SeStringRendererTestWidget.cs index 62c52a17f3..d0dffce753 100644 --- a/Dalamud/Interface/Internal/Windows/Data/Widgets/SeStringRendererTestWidget.cs +++ b/Dalamud/Interface/Internal/Windows/Data/Widgets/SeStringRendererTestWidget.cs @@ -1,4 +1,5 @@ using System.Linq; +using System.Numerics; using System.Text; using Dalamud.Data; @@ -6,7 +7,9 @@ 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; /// public string DisplayName { get; init; } = "SeStringRenderer Test"; @@ -45,12 +49,12 @@ internal unsafe class SeStringRendererTestWidget : IDataWindowWidget /// 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; } @@ -85,11 +89,11 @@ public void Draw() var t3 = this.style.LineHeight ?? 1f; if (ImGui.DragFloat("Line Height", ref t3, 0.01f, 0.4f, 3f, "%.02f")) this.style.LineHeight = t3; - + t3 = this.style.Opacity ?? ImGui.GetStyle().Alpha; if (ImGui.DragFloat("Opacity", ref t3, 0.005f, 0f, 1f, "%.02f")) this.style.Opacity = t3; - + t3 = this.style.EdgeStrength ?? 0.25f; if (ImGui.DragFloat("Edge Strength", ref t3, 0.005f, 0f, 1f, "%.02f")) this.style.EdgeStrength = t3; @@ -123,11 +127,15 @@ public void Draw() 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 @@ public void Draw() ImGuiHelpers.ScaledDummy(3); ImGuiHelpers.CompileSeStringWrapped( - "· For ease of testing, line breaks are automatically replaced to \\
.", + "Optional features implemented for the following test input:
" + + "· line breaks are automatically replaced to \\
.
" + + "· Dalamud will display Dalamud.
" + + "· White will display White.
" + + "· DefaultIcon will display DefaultIcon.
" + + "· DisabledIcon will display DisabledIcon.
" + + "· OutdatedInstallableIcon will display OutdatedInstallableIcon.
" + + "· TroubleIcon will display TroubleIcon.
" + + "· DevPluginIcon will display DevPluginIcon.
" + + "· UpdateIcon will display UpdateIcon.
" + + "· InstalledIcon will display InstalledIcon.
" + + "· ThirdIcon will display ThirdIcon.
" + + "· ThirdInstalledIcon will display ThirdInstalledIcon.
" + + "· ChangelogApiBumpIcon will display ChangelogApiBumpIcon.
" + + "· icon(5) will display icon(5). This is different from \\(5)>.
" + + "· tex(ui/loadingimage/-nowloading_base25_hr1.tex) will display tex(ui/loadingimage/-nowloading_base25_hr1.tex).", this.style); ImGuiHelpers.ScaledDummy(3); @@ -302,10 +325,14 @@ public void Draw() 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 @@ public void Draw() 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 + .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 + .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 + .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 + .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.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); + } }