using System.Collections.Generic; using System.Numerics; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Text; using BitFaster.Caching.Lru; using Dalamud.Bindings.ImGui; using Dalamud.Data; using Dalamud.Game; using Dalamud.Game.Text.SeStringHandling; using Dalamud.Interface.ImGuiSeStringRenderer.Internal.TextProcessing; using Dalamud.Interface.Utility; using Dalamud.Utility; using FFXIVClientStructs.FFXIV.Client.System.String; using FFXIVClientStructs.FFXIV.Client.UI; using Lumina.Excel.Sheets; using Lumina.Text; using Lumina.Text.Parse; using Lumina.Text.Payloads; using Lumina.Text.ReadOnly; using static Dalamud.Game.Text.SeStringHandling.BitmapFontIcon; namespace Dalamud.Interface.ImGuiSeStringRenderer.Internal; /// Draws SeString. [ServiceManager.EarlyLoadedService] internal class SeStringRenderer : IServiceType { private const int ImGuiContextCurrentWindowOffset = 0x3FF0; private const int ImGuiWindowDcOffset = 0x118; private const int ImGuiWindowTempDataCurrLineTextBaseOffset = 0x38; /// Soft hyphen character, which signifies that a word can be broken here, and will display a standard /// hyphen when it is broken there. private const int SoftHyphen = '\u00AD'; /// Object replacement character, which signifies that there should be something else displayed in place /// of this placeholder. On its own, usually displayed like [OBJ]. private const int ObjectReplacementCharacter = '\uFFFC'; /// Cache of compiled SeStrings from . private readonly ConcurrentLru cache = new(1024); /// Parsed gfdata.gfd file, containing bitmap font icon lookup table. private readonly GfdFile gfd; /// Parsed text fragments from a SeString. /// Touched only from the main thread. private readonly List fragmentsMainThread = []; /// Color stacks to use while evaluating a SeString for rendering. /// Touched only from the main thread. private readonly SeStringColorStackSet colorStackSetMainThread; [ServiceManager.ServiceConstructor] private SeStringRenderer(DataManager dm, TargetSigScanner sigScanner) { this.colorStackSetMainThread = new(dm.Excel.GetSheet()); this.gfd = dm.GetFile("common/font/gfdata.gfd")!; } /// Compiles and caches a SeString from a text macro representation. /// SeString text macro representation. /// Newline characters will be normalized to newline payloads. /// Compiled SeString. public ReadOnlySeString CompileAndCache(string text) => this.cache.GetOrAdd( text, static text => ReadOnlySeString.FromMacroString( text.ReplaceLineEndings("
"), new() { ExceptionMode = MacroStringParseExceptionMode.EmbedError })); /// Creates a draw data that will draw the given SeString onto it. /// SeString to render. /// Parameters for drawing. /// A new self-contained draw data. public unsafe BufferBackedImDrawData CreateDrawData( ReadOnlySeStringSpan sss, scoped in SeStringDrawParams drawParams = default) { if (drawParams.TargetDrawList is not null) { throw new ArgumentException( $"{nameof(SeStringDrawParams.TargetDrawList)} may not be specified.", nameof(drawParams)); } var dd = BufferBackedImDrawData.Create(); try { var size = this.Draw(sss, drawParams with { TargetDrawList = dd.ListPtr }).Size; var offset = drawParams.ScreenOffset ?? Vector2.Zero; foreach (var vtx in new Span(dd.ListPtr.VtxBuffer.Data, dd.ListPtr.VtxBuffer.Size)) offset = Vector2.Min(offset, vtx.Pos); dd.Data.DisplayPos = offset; dd.Data.DisplaySize = size - offset; dd.Data.Valid = 1; dd.UpdateDrawDataStatistics(); return dd; } catch { dd.Dispose(); throw; } } /// Compiles and caches a SeString from a text macro representation, and then draws it. /// SeString text macro representation. /// Newline characters will be normalized to newline payloads. /// Parameters for drawing. /// ImGui ID, if link functionality is desired. /// Button flags to use on link interaction. /// Interaction result of the rendered text. public SeStringDrawResult CompileAndDrawWrapped( string text, scoped in SeStringDrawParams drawParams = default, ImGuiId imGuiId = default, ImGuiButtonFlags buttonFlags = ImGuiButtonFlags.MouseButtonDefault) => this.Draw(this.CompileAndCache(text).AsSpan(), drawParams, imGuiId, buttonFlags); /// Draws a SeString. /// SeString to draw. /// Parameters for drawing. /// ImGui ID, if link functionality is desired. /// Button flags to use on link interaction. /// Interaction result of the rendered text. public SeStringDrawResult Draw( scoped in Utf8String utf8String, scoped in SeStringDrawParams drawParams = default, ImGuiId imGuiId = default, ImGuiButtonFlags buttonFlags = ImGuiButtonFlags.MouseButtonDefault) => this.Draw(utf8String.AsSpan(), drawParams, imGuiId, buttonFlags); /// Draws a SeString. /// SeString to draw. /// Parameters for drawing. /// ImGui ID, if link functionality is desired. /// Button flags to use on link interaction. /// Interaction result of the rendered text. public unsafe SeStringDrawResult Draw( ReadOnlySeStringSpan sss, scoped in SeStringDrawParams drawParams = default, ImGuiId imGuiId = default, ImGuiButtonFlags buttonFlags = ImGuiButtonFlags.MouseButtonDefault) { // Interactivity is supported only from the main thread. if (!imGuiId.IsEmpty()) ThreadSafety.AssertMainThread(); if (drawParams.TargetDrawList is not null && imGuiId) throw new ArgumentException("ImGuiId cannot be set if TargetDrawList is manually set.", nameof(imGuiId)); using var cleanup = new DisposeSafety.ScopedFinalizer(); ImFont* font = null; if (drawParams.Font.HasValue) font = drawParams.Font.Value; if (ThreadSafety.IsMainThread && drawParams.TargetDrawList is null && font is null) font = ImGui.GetFont(); if (font is null) throw new ArgumentException("Specified font is empty."); // This also does argument validation for drawParams. Do it here. // `using var` makes a struct read-only, but we do want to modify it. var stateStorage = new SeStringDrawState( sss, drawParams, ThreadSafety.IsMainThread ? this.colorStackSetMainThread : new(this.colorStackSetMainThread.ColorTypes), ThreadSafety.IsMainThread ? this.fragmentsMainThread : [], font); ref var state = ref Unsafe.AsRef(in stateStorage); // Analyze the provided SeString and break it up to text fragments. this.CreateTextFragments(ref state); var fragmentSpan = CollectionsMarshal.AsSpan(state.Fragments); // Calculate size. var size = Vector2.Zero; foreach (ref var fragment in fragmentSpan) size = Vector2.Max(size, fragment.Offset + new Vector2(fragment.VisibleWidth, state.LineHeight)); // If we're not drawing at all, stop further processing. if (state.DrawList.Handle is null) return new() { Size = size }; state.SplitDrawList(); var itemSize = size; if (drawParams.TargetDrawList is null) { // Handle cases where ImGui.AlignTextToFramePadding has been called. var currLineTextBaseOffset = ImGui.GetCurrentContext().CurrentWindow.DC.CurrLineTextBaseOffset; if (currLineTextBaseOffset != 0f) { itemSize.Y += 2 * currLineTextBaseOffset; foreach (ref var f in fragmentSpan) f.Offset += new Vector2(0, currLineTextBaseOffset); } } // Draw all text fragments. var lastRune = default(Rune); foreach (ref var f in fragmentSpan) { 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; } // Create an ImGui item, if a target draw list is not manually set. if (drawParams.TargetDrawList is null) ImGui.Dummy(itemSize); // Handle link interactions. var clicked = false; var hoveredLinkOffset = -1; var activeLinkOffset = -1; if (imGuiId.PushId()) { var invisibleButtonDrawn = false; foreach (ref readonly var f in fragmentSpan) { if (f.Link == -1) continue; var pos = ImGui.GetMousePos() - state.ScreenOffset - f.Offset; 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; var cursorPosBackup = ImGui.GetCursorScreenPos(); ImGui.SetCursorScreenPos(state.ScreenOffset + f.Offset); clicked = ImGui.InvisibleButton("##link"u8, sz, buttonFlags); if (ImGui.IsItemHovered()) hoveredLinkOffset = f.Link; if (ImGui.IsItemActive()) activeLinkOffset = f.Link; ImGui.SetCursorScreenPos(cursorPosBackup); break; } } // If no link was hovered and thus no invisible button is put, treat the whole area as the button. if (!invisibleButtonDrawn) { ImGui.SetCursorScreenPos(state.ScreenOffset); clicked = ImGui.InvisibleButton("##text"u8, itemSize, buttonFlags); } ImGui.PopID(); } // If any link is being interacted, draw rectangles behind the relevant text fragments. if (hoveredLinkOffset != -1 || activeLinkOffset != -1) { 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; var offset = state.ScreenOffset + fragment.Offset; state.DrawList.AddRectFilled( offset, offset + new Vector2(fragment.AdvanceWidth, state.LineHeight), color); } } state.MergeDrawList(); var payloadEnumerator = new ReadOnlySeStringSpan( hoveredLinkOffset == -1 ? ReadOnlySpan.Empty : sss.Data[hoveredLinkOffset..]).GetEnumerator(); if (!payloadEnumerator.MoveNext()) return new() { Size = size, Clicked = clicked, InteractedPayloadOffset = -1 }; return new() { Size = size, Clicked = clicked, InteractedPayloadOffset = hoveredLinkOffset, InteractedPayloadEnvelope = sss.Data.Slice(hoveredLinkOffset, payloadEnumerator.Current.EnvelopeByteLength), }; } /// Gets the effective char for the given char, or null(\0) if it should not be handled at all. /// Character to determine. /// Corresponding rune. /// Whether to display soft hyphens. /// Rune corresponding to the unicode codepoint to process, or null(\0) if none. private static bool TryGetDisplayRune(Rune rune, out Rune displayRune, bool displaySoftHyphen = true) { displayRune = rune.Value switch { 0 or char.MaxValue => default, SoftHyphen => displaySoftHyphen ? new('-') : default, _ when UnicodeData.LineBreak[rune.Value] is UnicodeLineBreakClass.BK or UnicodeLineBreakClass.CR or UnicodeLineBreakClass.LF or UnicodeLineBreakClass.NL => new(0), _ => rune, }; return displayRune.Value != 0; } /// Creates text fragment, taking line and word breaking into account. /// Draw state. private void CreateTextFragments(ref SeStringDrawState state) { var prev = 0; var xy = Vector2.Zero; var w = 0f; var link = -1; foreach (var (breakAt, mandatory) in new LineBreakEnumerator(state.Span, UtfEnumeratorFlags.Utf8SeString)) { // 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 and custom entities. foreach (var p in new ReadOnlySeStringSpan(state.Span[prev..breakAt]).GetOffsetEnumerator()) { var break2 = false; switch (p.Payload.Type) { 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 ? -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; } case ReadOnlySePayloadType.Invalid: default: break; } if (break2) break; } // Create a text fragment without applying wrap width limits for testing. 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 (xy.X != 0 && state.Fragments.Count > 0 && !state.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.LineHeight; w = 0; CollectionsMarshal.AsSpan(state.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) { // 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 (state.Fragments.Count > 0 && xy.X != 0) { // New fragment fits into the current line, and it has a previous fragment in the same line. // If the previous fragment ends with a soft hyphen, adjust its width so that the width of its // trailing soft hyphens are not considered. if (state.Fragments[^1].EndsWithSoftHyphen) xy.X += state.Fragments[^1].AdvanceWidthWithoutSoftHyphen - state.Fragments[^1].AdvanceWidth; // Adjust this fragment's offset from kerning distance. xy.X += state.CalculateScaledDistance(state.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) link = nextLink; w = Math.Max(w, xy.X + fragment.VisibleWidth); xy.X += fragment.AdvanceWidth; prev = fragment.To; state.Fragments.Add(fragment); if (fragment.BreakAfter) { xy.X = w = 0; xy.Y += state.LineHeight; } } } } /// Draws a text fragment. /// Draw state. /// Offset of left top corner of this text fragment in pixels w.r.t. /// . /// Whether to display soft hyphens in this text fragment. /// 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. private void DrawTextFragment( ref SeStringDrawState state, Vector2 offset, bool displaySoftHyphen, ReadOnlySpan span, Rune lastRune, int link) { // This might temporarily return 0 while logging in. var gfdTextureSrv = GetGfdTextureSrv(); var x = 0f; var width = 0f; foreach (var c in UtfEnumerator.From(span, UtfEnumeratorFlags.Utf8SeString)) { if (c is { IsSeStringPayload: true, EffectiveInt: char.MaxValue or ObjectReplacementCharacter }) { var enu = new ReadOnlySeStringSpan(span[c.ByteOffset..]).GetOffsetEnumerator(); if (!enu.MoveNext()) continue; if (state.HandleStyleAdjustingPayloads(enu.Current.Payload)) 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); if (gfdTextureSrv != 0) { state.Draw( new(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.CalculateScaledDistance(lastRune, rune); var advanceWidth = MathF.Round(g.AdvanceX * state.FontSizeScale); lastRune = rune; state.DrawGlyph(g, offset + new Vector2(x + dist, 0)); if (link != -1) state.DrawLinkUnderline(offset + new Vector2(x + dist, 0), advanceWidth); width = Math.Max(width, x + dist + (g.X1 * state.FontSizeScale)); x += dist + advanceWidth; } return; static unsafe nint GetGfdTextureSrv() { var uim = UIModule.Instance(); if (uim is null) return 0; var ram = uim->GetRaptureAtkModule(); if (ram is null) return 0; var gfd = ram->AtkModule.AtkFontManager.Gfd; if (gfd is null) return 0; var tex = gfd->Texture; if (tex is null) return 0; return (nint)tex->D3D11ShaderResourceView; } } /// Determines a bitmap icon to display for the given SeString payload. /// Byte span that should include a SeString payload. /// Icon to display, or if it should not be displayed as an icon. private unsafe BitmapFontIcon GetBitmapFontIconFor(ReadOnlySpan sss) { var e = new ReadOnlySeStringSpan(sss).GetEnumerator(); if (!e.MoveNext() || e.Current.MacroCode is not MacroCode.Icon and not MacroCode.Icon2) return None; var payload = e.Current; switch (payload.MacroCode) { // Show the specified icon as-is. case MacroCode.Icon when payload.TryGetExpression(out var icon) && icon.TryGetInt(out var iconId): return (BitmapFontIcon)iconId; // Apply gamepad key mapping to icons. case MacroCode.Icon2 when payload.TryGetExpression(out var icon) && icon.TryGetInt(out var iconId): ref var iconMapping = ref RaptureAtkModule.Instance()->AtkFontManager.Icon2RemapTable; for (var i = 0; i < 30; i++) { if (iconMapping[i].IconId == iconId) { return (BitmapFontIcon)iconMapping[i].RemappedIconId; } } return (BitmapFontIcon)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); } }