using Dalamud.Bindings.ImGui; using Dalamud.Game.Text.SeStringHandling; using Dalamud.Game.Text.SeStringHandling.Payloads; using Dalamud.Interface; using Dalamud.Interface.ImGuiSeStringRenderer; using Dalamud.Interface.Utility; using Dalamud.Interface.Textures.TextureWraps; using Lumina.Text; using Lumina.Text.Parse; using Lumina.Text.ReadOnly; using System; using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Numerics; using System.Reflection; using System.Text; using System.Threading; using DalamudSeString = Dalamud.Game.Text.SeStringHandling.SeString; using DalamudSeStringBuilder = Dalamud.Game.Text.SeStringHandling.SeStringBuilder; using LuminaSeStringBuilder = Lumina.Text.SeStringBuilder; namespace LightlessSync.Utils; public static class SeStringUtils { private static int _seStringHitboxCounter; private static int _iconHitboxCounter; public static bool TryRenderSeStringMarkupAtCursor(string payload) { if (string.IsNullOrWhiteSpace(payload)) return false; var wrapWidth = ImGui.GetContentRegionAvail().X; if (wrapWidth <= 0f || float.IsNaN(wrapWidth) || float.IsInfinity(wrapWidth)) wrapWidth = float.MaxValue; var normalizedPayload = payload.ReplaceLineEndings("
"); try { _ = ReadOnlySeString.FromMacroString(normalizedPayload, new MacroStringParseOptions { ExceptionMode = MacroStringParseExceptionMode.Throw }); } catch (Exception) { return false; } try { var drawParams = new SeStringDrawParams { WrapWidth = wrapWidth, Font = ImGui.GetFont(), Color = ImGui.GetColorU32(ImGuiCol.Text), }; var renderId = ImGui.GetID($"SeStringMarkup##{normalizedPayload.GetHashCode()}"); var drawResult = ImGuiHelpers.CompileSeStringWrapped(normalizedPayload, drawParams, renderId); var height = drawResult.Size.Y; if (height <= 0f) height = ImGui.GetTextLineHeight(); ImGui.Dummy(new Vector2(0f, height)); if (drawResult.InteractedPayloadEnvelope.Length > 0 && TryExtractLink(drawResult.InteractedPayloadEnvelope, drawResult.InteractedPayload, out var linkUrl, out var tooltipText)) { var hoverText = !string.IsNullOrEmpty(linkUrl) ? linkUrl : tooltipText; if (!string.IsNullOrEmpty(hoverText)) { ImGui.BeginTooltip(); ImGui.TextUnformatted(hoverText); ImGui.EndTooltip(); } if (!string.IsNullOrEmpty(linkUrl)) ImGui.SetMouseCursor(ImGuiMouseCursor.Hand); if (drawResult.Clicked && !string.IsNullOrEmpty(linkUrl)) Dalamud.Utility.Util.OpenLink(linkUrl); } return true; } catch (Exception ex) { ImGui.TextDisabled($"[SeString error] {ex.Message}"); return false; } } public enum SeStringSegmentType { Text, Icon } public readonly record struct SeStringSegment( SeStringSegmentType Type, string? Text, Vector4? Color, uint IconId, IDalamudTextureWrap? Texture, Vector2 Size); public static bool TryResolveSegments( string payload, float scale, Func iconResolver, List resolvedSegments, out Vector2 totalSize) { totalSize = Vector2.Zero; if (string.IsNullOrWhiteSpace(payload)) return false; var parsedSegments = new List(Math.Max(1, payload.Length / 4)); if (!ParseSegments(payload, parsedSegments)) return false; float width = 0f; float height = 0f; foreach (var segment in parsedSegments) { switch (segment.Type) { case ParsedSegmentType.Text: { var text = segment.Text ?? string.Empty; if (text.Length == 0) break; var textSize = ImGui.CalcTextSize(text); resolvedSegments.Add(new SeStringSegment(SeStringSegmentType.Text, text, segment.Color, 0, null, textSize)); width += textSize.X; height = MathF.Max(height, textSize.Y); break; } case ParsedSegmentType.Icon: { var wrap = iconResolver(segment.IconId); Vector2 iconSize; string? fallback = null; if (wrap != null) { iconSize = CalculateIconSize(wrap, scale); } else { fallback = $"[{segment.IconId}]"; iconSize = ImGui.CalcTextSize(fallback); } resolvedSegments.Add(new SeStringSegment(SeStringSegmentType.Icon, fallback, segment.Color, segment.IconId, wrap, iconSize)); width += iconSize.X; height = MathF.Max(height, iconSize.Y); break; } } } totalSize = new Vector2(width, height); parsedSegments.Clear(); return resolvedSegments.Count > 0; } private enum ParsedSegmentType { Text, Icon } private readonly record struct ParsedSegment( ParsedSegmentType Type, string? Text, uint IconId, Vector4? Color); private static bool ParseSegments(string payload, List segments) { var builder = new StringBuilder(payload.Length); Vector4? activeColor = null; var index = 0; while (index < payload.Length) { if (payload[index] == '<') { var end = payload.IndexOf('>', index); if (end == -1) break; var tagContent = payload.Substring(index + 1, end - index - 1); if (TryHandleIconTag(tagContent, segments, builder, activeColor)) { index = end + 1; continue; } if (TryHandleColorTag(tagContent, segments, builder, ref activeColor)) { index = end + 1; continue; } builder.Append('<'); builder.Append(tagContent); builder.Append('>'); index = end + 1; } else { builder.Append(payload[index]); index++; } } if (index < payload.Length) builder.Append(payload, index, payload.Length - index); FlushTextBuilder(builder, activeColor, segments); return segments.Count > 0; } private static bool TryHandleIconTag(string tagContent, List segments, StringBuilder textBuilder, Vector4? activeColor) { if (!tagContent.StartsWith("icon(", StringComparison.OrdinalIgnoreCase) || !tagContent.EndsWith(')')) return false; var inner = tagContent.AsSpan(5, tagContent.Length - 6).Trim(); if (!uint.TryParse(inner, NumberStyles.Integer, CultureInfo.InvariantCulture, out var iconId)) return false; FlushTextBuilder(textBuilder, activeColor, segments); segments.Add(new ParsedSegment(ParsedSegmentType.Icon, null, iconId, null)); return true; } private static bool TryHandleColorTag(string tagContent, List segments, StringBuilder textBuilder, ref Vector4? activeColor) { if (tagContent.StartsWith("color", StringComparison.OrdinalIgnoreCase)) { var equalsIndex = tagContent.IndexOf('='); if (equalsIndex == -1) return false; var value = tagContent.Substring(equalsIndex + 1).Trim().Trim('"'); if (!TryParseColor(value, out var color)) return false; FlushTextBuilder(textBuilder, activeColor, segments); activeColor = color; return true; } if (tagContent.Equals("/color", StringComparison.OrdinalIgnoreCase)) { FlushTextBuilder(textBuilder, activeColor, segments); activeColor = null; return true; } return false; } private static void FlushTextBuilder(StringBuilder builder, Vector4? color, List segments) { if (builder.Length == 0) return; segments.Add(new ParsedSegment(ParsedSegmentType.Text, builder.ToString(), 0, color)); builder.Clear(); } private static bool TryExtractLink(ReadOnlySpan envelope, Payload? payload, out string url, out string? tooltipText) { url = string.Empty; tooltipText = null; if (envelope.Length == 0 && payload is null) return false; tooltipText = envelope.Length > 0 ? DalamudSeString.Parse(envelope.ToArray()).TextValue : null; if (payload is not null && TryReadUrlFromPayload(payload, out url)) return true; if (!string.IsNullOrWhiteSpace(tooltipText)) { var candidate = FindFirstUrl(tooltipText); if (!string.IsNullOrEmpty(candidate)) { url = candidate; return true; } } url = string.Empty; return false; } private static bool TryReadUrlFromPayload(object payload, out string url) { url = string.Empty; var type = payload.GetType(); static string? ReadStringProperty(Type type, object instance, string propertyName) => type.GetProperty(propertyName, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) ?.GetValue(instance) as string; string? candidate = ReadStringProperty(type, payload, "Uri") ?? ReadStringProperty(type, payload, "Url") ?? ReadStringProperty(type, payload, "Target") ?? ReadStringProperty(type, payload, "Destination"); if (IsHttpUrl(candidate)) { url = candidate!; return true; } var dataProperty = type.GetProperty("Data", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); if (dataProperty?.GetValue(payload) is IEnumerable sequence) { foreach (var entry in sequence) { if (IsHttpUrl(entry)) { url = entry; return true; } } } var extraStringProp = type.GetProperty("ExtraString", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); if (IsHttpUrl(extraStringProp?.GetValue(payload) as string)) { url = (extraStringProp!.GetValue(payload) as string)!; return true; } var textProp = type.GetProperty("Text", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); if (IsHttpUrl(textProp?.GetValue(payload) as string)) { url = (textProp!.GetValue(payload) as string)!; return true; } return false; } private static string? FindFirstUrl(string? text) { if (string.IsNullOrWhiteSpace(text)) return null; var index = text.IndexOf("http", StringComparison.OrdinalIgnoreCase); while (index >= 0) { var end = index; while (end < text.Length && !char.IsWhiteSpace(text[end]) && !"\"')]>".Contains(text[end])) end++; var candidate = text.Substring(index, end - index).TrimEnd('.', ',', ';'); if (IsHttpUrl(candidate)) return candidate; index = text.IndexOf("http", end, StringComparison.OrdinalIgnoreCase); } return null; } private static bool IsHttpUrl(string? value) { if (string.IsNullOrWhiteSpace(value)) return false; return Uri.TryCreate(value, UriKind.Absolute, out var uri) && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps); } public static string StripMarkup(string value) { if (string.IsNullOrEmpty(value)) return string.Empty; var builder = new StringBuilder(value.Length); int depth = 0; foreach (var c in value) { if (c == '<') { depth++; continue; } if (c == '>' && depth > 0) { depth--; continue; } if (depth == 0) builder.Append(c); } return builder.ToString().Trim(); } private static bool TryParseColor(string value, out Vector4 color) { color = default; if (string.IsNullOrEmpty(value) || value[0] != '#') return false; var hex = value.AsSpan(1); if (!uint.TryParse(hex, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var parsed)) return false; byte a, r, g, b; if (hex.Length == 6) { a = 0xFF; r = (byte)(parsed >> 16); g = (byte)(parsed >> 8); b = (byte)parsed; } else if (hex.Length == 8) { a = (byte)(parsed >> 24); r = (byte)(parsed >> 16); g = (byte)(parsed >> 8); b = (byte)parsed; } else { return false; } const float inv = 1.0f / 255f; color = new Vector4(r * inv, g * inv, b * inv, a * inv); return true; } private static Vector2 CalculateIconSize(IDalamudTextureWrap wrap, float scale) { const float IconHeightScale = 1.25f; var textHeight = ImGui.GetTextLineHeight(); var baselineHeight = MathF.Max(1f, textHeight - 2f * scale); var targetHeight = MathF.Max(baselineHeight, baselineHeight * IconHeightScale); var aspect = wrap.Width > 0 ? wrap.Width / (float)wrap.Height : 1f; return new Vector2(targetHeight * aspect, targetHeight); } public static DalamudSeString BuildFormattedPlayerName(string text, Vector4? textColor, Vector4? glowColor) { var b = new DalamudSeStringBuilder(); if (glowColor is Vector4 glow) b.Add(new GlowPayload(glow)); if (textColor is Vector4 color) b.Add(new ColorPayload(color)); b.AddText(text ?? string.Empty); if (textColor is not null) b.Add(new ColorEndPayload()); if (glowColor is not null) b.Add(new GlowEndPayload()); return b.Build(); } public static DalamudSeString BuildPlain(string text) { var b = new DalamudSeStringBuilder(); b.AddText(text ?? string.Empty); return b.Build(); } public static DalamudSeString BuildRichText(ReadOnlySpan fragments) { var builder = new LuminaSeStringBuilder(); foreach (var fragment in fragments) { if (string.IsNullOrEmpty(fragment.Text)) continue; var hasColor = fragment.Color.HasValue; if (hasColor) { Vector4 color = fragment.Color!.Value; builder.PushColorRgba(color); } if (fragment.Bold) builder.AppendSetBold(true); builder.Append(fragment.Text.AsSpan()); if (fragment.Bold) builder.AppendSetBold(false); if (hasColor) builder.PopColor(); } return DalamudSeString.Parse(builder.ToArray()); } public static DalamudSeString BuildRichText(params RichTextEntry[] fragments) => BuildRichText(fragments.AsSpan()); public static void RenderSeString(DalamudSeString seString, Vector2 position, ImFontPtr? font = null, ImDrawListPtr? drawList = null) { drawList ??= ImGui.GetWindowDrawList(); var drawParams = new SeStringDrawParams { Font = font ?? UiBuilder.MonoFont, Color = 0xFFFFFFFF, WrapWidth = float.MaxValue, TargetDrawList = drawList }; ImGui.SetCursorScreenPos(position); ImGuiHelpers.SeStringWrapped(seString.Encode(), drawParams); var textSize = ImGui.CalcTextSize(seString.TextValue); if (textSize.Y <= 0f) textSize.Y = ImGui.GetTextLineHeight(); ImGui.Dummy(new Vector2(0f, textSize.Y)); } public static void RenderSeStringWrapped(DalamudSeString seString, float wrapWidth, ImFontPtr? font = null, ImDrawListPtr? drawList = null) { drawList ??= ImGui.GetWindowDrawList(); var drawParams = new SeStringDrawParams { Font = font ?? ImGui.GetFont(), Color = ImGui.GetColorU32(ImGuiCol.Text), WrapWidth = wrapWidth, TargetDrawList = drawList }; ImGuiHelpers.SeStringWrapped(seString.Encode(), drawParams); var calcWrapWidth = wrapWidth > 0f ? wrapWidth : -1f; var textSize = ImGui.CalcTextSize(seString.TextValue, wrapWidth: calcWrapWidth); if (textSize.Y <= 0f) textSize.Y = ImGui.GetTextLineHeight(); ImGui.Dummy(new Vector2(0f, textSize.Y)); } public static Vector2 RenderSeStringWithHitbox(DalamudSeString seString, Vector2 position, ImFontPtr? font = null, string? id = null) { var drawList = ImGui.GetWindowDrawList(); var usedFont = font ?? UiBuilder.MonoFont; var drawParams = new SeStringDrawParams { Font = usedFont, Color = 0xFFFFFFFF, WrapWidth = float.MaxValue, TargetDrawList = drawList }; var textSize = ImGui.CalcTextSize(seString.TextValue); if (textSize.Y <= 0f) { textSize.Y = usedFont.FontSize; } var style = ImGui.GetStyle(); var fontHeight = usedFont.FontSize > 0f ? usedFont.FontSize : ImGui.GetFontSize(); var frameHeight = fontHeight + style.FramePadding.Y * 2f; var hitboxHeight = MathF.Max(frameHeight, textSize.Y); var verticalOffset = MathF.Max((hitboxHeight - textSize.Y) * 0.5f, 0f); var drawPos = new Vector2(position.X, position.Y + verticalOffset); ImGui.SetCursorScreenPos(drawPos); drawParams.ScreenOffset = drawPos; drawParams.Font = usedFont; drawParams.FontSize = usedFont.FontSize; ImGuiHelpers.SeStringWrapped(seString.Encode(), drawParams); ImGui.SetCursorScreenPos(position); if (id is not null) { ImGui.PushID(id); } else { ImGui.PushID(Interlocked.Increment(ref _seStringHitboxCounter)); } try { ImGui.InvisibleButton("##hitbox", new Vector2(textSize.X, hitboxHeight)); } finally { ImGui.PopID(); } return new Vector2(textSize.X, hitboxHeight); } public static Vector2 RenderIconWithHitbox(int iconId, Vector2 position, ImFontPtr? font = null, string? id = null) { var drawList = ImGui.GetWindowDrawList(); var usedFont = font ?? UiBuilder.MonoFont; var iconMacro = $""; var measureParams = new SeStringDrawParams { Font = usedFont, FontSize = usedFont.FontSize, Color = 0xFFFFFFFF, WrapWidth = float.MaxValue }; var measureResult = ImGuiHelpers.CompileSeStringWrapped(iconMacro, measureParams); var iconSize = measureResult.Size; if (iconSize.Y <= 0f) { iconSize.Y = usedFont.FontSize > 0f ? usedFont.FontSize : ImGui.GetFontSize(); } var style = ImGui.GetStyle(); var fontHeight = usedFont.FontSize > 0f ? usedFont.FontSize : ImGui.GetFontSize(); var frameHeight = fontHeight + style.FramePadding.Y * 2f; var hitboxHeight = MathF.Max(frameHeight, iconSize.Y); var verticalOffset = MathF.Max((hitboxHeight - iconSize.Y) * 0.5f, 0f); var drawPos = new Vector2(position.X, position.Y + verticalOffset); var drawParams = new SeStringDrawParams { Font = usedFont, FontSize = usedFont.FontSize, Color = 0xFFFFFFFF, WrapWidth = float.MaxValue, TargetDrawList = drawList, ScreenOffset = drawPos }; ImGuiHelpers.CompileSeStringWrapped(iconMacro, drawParams); ImGui.SetCursorScreenPos(position); if (id is not null) { ImGui.PushID(id); } else { ImGui.PushID(Interlocked.Increment(ref _iconHitboxCounter)); } try { ImGui.InvisibleButton("##iconHitbox", new Vector2(iconSize.X, hitboxHeight)); } finally { ImGui.PopID(); } return new Vector2(iconSize.X, hitboxHeight); } #region Internal Payloads public readonly record struct RichTextEntry(string Text, Vector4? Color = null, bool Bold = false); private abstract class AbstractColorPayload : Payload { protected byte Red { get; init; } protected byte Green { get; init; } protected byte Blue { get; init; } protected override byte[] EncodeImpl() { return new byte[] { 0x02, ChunkType, 0x05, 0xF6, Red, Green, Blue, 0x03 }; } protected override void DecodeImpl(BinaryReader reader, long endOfStream) { } public override PayloadType Type => PayloadType.Unknown; protected abstract byte ChunkType { get; } } private abstract class AbstractColorEndPayload : Payload { protected override byte[] EncodeImpl() { return new byte[] { 0x02, ChunkType, 0x02, 0xEC, 0x03 }; } protected override void DecodeImpl(BinaryReader reader, long endOfStream) { } public override PayloadType Type => PayloadType.Unknown; protected abstract byte ChunkType { get; } } private sealed class ColorPayload : AbstractColorPayload { protected override byte ChunkType => 0x13; public ColorPayload(Vector3 color) { Red = Math.Max((byte)1, (byte)(color.X * 255f)); Green = Math.Max((byte)1, (byte)(color.Y * 255f)); Blue = Math.Max((byte)1, (byte)(color.Z * 255f)); } public ColorPayload(Vector4 color) : this(new Vector3(color.X, color.Y, color.Z)) { } } private sealed class ColorEndPayload : AbstractColorEndPayload { protected override byte ChunkType => 0x13; } private sealed class GlowPayload : AbstractColorPayload { protected override byte ChunkType => 0x14; public GlowPayload(Vector3 color) { Red = Math.Max((byte)1, (byte)(color.X * 255f)); Green = Math.Max((byte)1, (byte)(color.Y * 255f)); Blue = Math.Max((byte)1, (byte)(color.Z * 255f)); } public GlowPayload(Vector4 color) : this(new Vector3(color.X, color.Y, color.Z)) { } } private sealed class GlowEndPayload : AbstractColorEndPayload { protected override byte ChunkType => 0x14; } #endregion }