From 4701bb3f6d2de07341cb9d8c40ae6bbd35834ad4 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Tue, 9 Apr 2024 00:49:15 +1000 Subject: [PATCH 1/2] feat: clickable URLs in chat log Adds a parsing step when constructing `Message` objects that scans the message content for anything that looks URL-like, and inserts new `TextChunk`s into the message content with a URIPayload set. Hovering over a URL shows an on-hover effect. Clicking a URL opens it in the default browser. Right clicking shows the hostname, with an option to open and an option to copy the URL to the clipboard. --- .editorconfig | 6 ++ ChatTwo/Chunk.cs | 13 ++++ ChatTwo/Message.cs | 89 +++++++++++++++++++++++++- ChatTwo/PayloadHandler.cs | 54 ++++++++++++++++ ChatTwo/Resources/Language.Designer.cs | 54 ++++++++++++++++ ChatTwo/Resources/Language.resx | 18 ++++++ ChatTwo/Store.cs | 6 ++ ChatTwo/Util/ChunkUtil.cs | 5 ++ ChatTwo/Util/ImGuiUtil.cs | 6 +- ChatTwo/Util/Payloads.cs | 48 ++++++++++++++ 10 files changed, 295 insertions(+), 4 deletions(-) create mode 100644 .editorconfig diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..ca3df2c --- /dev/null +++ b/.editorconfig @@ -0,0 +1,6 @@ +[*] +indent_style = space +tab_width = 4 +indent_size = 4 +trim_trailing_whitespace = true +insert_final_newline = true diff --git a/ChatTwo/Chunk.cs b/ChatTwo/Chunk.cs index d88c263..fd755ba 100755 --- a/ChatTwo/Chunk.cs +++ b/ChatTwo/Chunk.cs @@ -59,6 +59,19 @@ internal class TextChunk : Chunk { public TextChunk() : base(ChunkSource.None, null) { } #pragma warning restore CS8618 + + /// + /// Creates a new TextChunk with identical styling to this one. + /// + public TextChunk NewWithStyle(ChunkSource source, Payload? link, string content) + { + return new TextChunk(source, link, content) { + FallbackColour = this.FallbackColour, + Foreground = this.Foreground, + Glow = this.Glow, + Italic = this.Italic, + }; + } } internal class IconChunk : Chunk { diff --git a/ChatTwo/Message.cs b/ChatTwo/Message.cs index bcba4ce..f0cc9fc 100755 --- a/ChatTwo/Message.cs +++ b/ChatTwo/Message.cs @@ -1,7 +1,9 @@ using ChatTwo.Code; +using ChatTwo.Util; using Dalamud.Game.Text.SeStringHandling; using Dalamud.Game.Text.SeStringHandling.Payloads; using LiteDB; +using System.Text.RegularExpressions; namespace ChatTwo; @@ -70,7 +72,7 @@ internal class Message { this.Date = DateTime.UtcNow; this.Code = code; this.Sender = sender; - this.Content = content; + this.Content = ReplaceContentURLs(content); this.SenderSource = senderSource; this.ContentSource = contentSource; this.SortCode = new SortCode(this.Code.Type, this.Code.Source); @@ -89,6 +91,8 @@ internal class Message { this.Date = date; this.Code = BsonMapper.Global.ToObject(code); this.Sender = BsonMapper.Global.Deserialize>(sender); + // Don't call ReplaceContentURLs here since we're loading the message + // from the database and it should already have parsed URL data. this.Content = BsonMapper.Global.Deserialize>(content); this.SenderSource = BsonMapper.Global.Deserialize(senderSource); this.ContentSource = BsonMapper.Global.Deserialize(contentSource); @@ -108,6 +112,8 @@ internal class Message { this.Date = date; this.Code = BsonMapper.Global.ToObject(code); this.Sender = BsonMapper.Global.Deserialize>(sender); + // Don't call ReplaceContentURLs here since we're loading the message + // from the database and it should already have parsed URL data. this.Content = BsonMapper.Global.Deserialize>(content); this.SenderSource = BsonMapper.Global.Deserialize(senderSource); this.ContentSource = BsonMapper.Global.Deserialize(contentSource); @@ -138,4 +144,85 @@ internal class Message { return Guid.Empty; } + + /// + /// URLRegex returns a regex object that matches URLs like: + /// - https://example.com + /// - http://example.com + /// - www.example.com + /// - https://sub.example.com + /// - example.com + /// - sub.example.com + /// + /// It matches URLs with www. or https:// prefix, and also matches URLs + /// without a prefix on specific TLDs. + /// + private static Regex URLRegex = new Regex( + @"((https?:\/\/|www\.)[a-z0-9-]+(\.[a-z0-9-]+)*|([a-z0-9-]+(\.[a-z0-9-]+)*\.(com|net|org|co|io|app)))(:[\d]{1,5})?(\/[^\s]+)?", + RegexOptions.Compiled | RegexOptions.IgnoreCase + ); + + /// + /// Finds all URL strings in all TextChunks, splits the parent TextChunk + /// apart and inserts a new TextChunk with a URIPayload. + /// + private List ReplaceContentURLs(List content) + { + var newChunks = new List(); + void AddChunkWithMessage(Chunk chunk) { + chunk.Message = this; + newChunks.Add(chunk); + } + + foreach (var chunk in content) + { + // Use as is if it's not a text chunk or it already has a payload. + if (chunk is not TextChunk text || chunk.Link != null) + { + // No need to call AddChunkWithMessage here since the chunk + // already has the Message field set. + newChunks.Add(chunk); + continue; + } + + // Find all URLs with the regex and insert a new TextChunk with a + // URIPayload. + var matches = URLRegex.Matches(text.Content); + var remainderIndex = 0; + foreach (Match match in matches.Cast()) + { + // Add the text before the URL. + if (match.Index > remainderIndex) + { + AddChunkWithMessage(text.NewWithStyle(chunk.Source, chunk.Link, text.Content[remainderIndex..match.Index])); + } + + // Update the remainder index. + remainderIndex = match.Index + match.Length; + + // Create a new TextChunk with a URIPayload for the URL text. + try + { + var link = URIPayload.ResolveURI(match.Value); + AddChunkWithMessage(text.NewWithStyle(chunk.Source, link, match.Value)); + } + catch (UriFormatException) + { + Plugin.Log.Debug($"Invalid URL accepted by Regex but failed URI parsing: '{match.Value}'"); + // If the URL is invalid, set the remainder index to the + // beginning of the match so it'll get included in the next + // regular text chunk. + remainderIndex = match.Index; + } + } + + // Add the text after the last URL. + if (remainderIndex < text.Content.Length) + { + AddChunkWithMessage(text.NewWithStyle(chunk.Source, null, text.Content[remainderIndex..])); + } + } + + return newChunks; + } } diff --git a/ChatTwo/PayloadHandler.cs b/ChatTwo/PayloadHandler.cs index a971615..e448125 100755 --- a/ChatTwo/PayloadHandler.cs +++ b/ChatTwo/PayloadHandler.cs @@ -21,6 +21,7 @@ using Lumina.Excel.GeneratedSheets; using Action = System.Action; using DalamudPartyFinderPayload = Dalamud.Game.Text.SeStringHandling.Payloads.PartyFinderPayload; using ChatTwoPartyFinderPayload = ChatTwo.Util.PartyFinderPayload; +using System.Diagnostics; namespace ChatTwo; @@ -86,6 +87,11 @@ public sealed class PayloadHandler { drawn = true; break; } + case URIPayload uri: { + DrawUriPopup(uri); + drawn = true; + break; + } } ContextFooter(drawn, chunk); @@ -215,6 +221,11 @@ public sealed class PayloadHandler { DoHover(() => HoverItem(item), hoverSize); break; } + case URIPayload uri: + { + DoHover(() => HoverURI(uri), hoverSize); + break; + } } } @@ -334,6 +345,11 @@ public sealed class PayloadHandler { } } + private void HoverURI(URIPayload uri) { + ImGui.TextUnformatted(string.Format(Language.Context_URLDomain, uri.Uri.Authority)); + ImGuiUtil.WarningText(Language.Context_URLWarning); + } + private void LeftClickPayload(Chunk chunk, Payload? payload) { switch (payload) { case MapLinkPayload map: { @@ -372,6 +388,10 @@ public sealed class PayloadHandler { break; } + case URIPayload uri: { + TryOpenURI(uri.Uri); + break; + } } } @@ -625,4 +645,38 @@ public sealed class PayloadHandler { return null; } + + private void DrawUriPopup(URIPayload uri) + { + ImGui.TextUnformatted(string.Format(Language.Context_URLDomain, uri.Uri.Authority)); + ImGuiUtil.WarningText(Language.Context_URLWarning, false); + ImGui.Separator(); + + if (ImGui.Selectable(Language.Context_OpenInBrowser)) + { + TryOpenURI(uri.Uri); + } + + if (ImGui.Selectable(Language.Context_CopyLink)) + { + ImGui.SetClipboardText(uri.Uri.ToString()); + WrapperUtil.AddNotification(Language.Context_CopyLinkNotification, NotificationType.Info); + } + } + + private void TryOpenURI(Uri uri) + { + new Thread(() => { + try + { + Plugin.Log.Info($"Opening URI {uri} in default browser"); + Process.Start(new ProcessStartInfo(uri.ToString()) { UseShellExecute = true }); + } + catch (Exception ex) + { + Plugin.Log.Error($"Error opening URI: {ex}"); + WrapperUtil.AddNotification(Language.Context_OpenInBrowserError, NotificationType.Error); + } + }).Start(); + } } diff --git a/ChatTwo/Resources/Language.Designer.cs b/ChatTwo/Resources/Language.Designer.cs index 2412b26..deda984 100755 --- a/ChatTwo/Resources/Language.Designer.cs +++ b/ChatTwo/Resources/Language.Designer.cs @@ -1040,6 +1040,24 @@ namespace ChatTwo.Resources { } } + /// + /// Looks up a localized string similar to Copy link to clipboard. + /// + internal static string Context_CopyLink { + get { + return ResourceManager.GetString("Context_CopyLink", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Copied link to clipboard. + /// + internal static string Context_CopyLinkNotification { + get { + return ResourceManager.GetString("Context_CopyLinkNotification", resourceCulture); + } + } + /// /// Looks up a localized string similar to Hide chat. /// @@ -1121,6 +1139,24 @@ namespace ChatTwo.Resources { } } + /// + /// Looks up a localized string similar to Open link in browser. + /// + internal static string Context_OpenInBrowser { + get { + return ResourceManager.GetString("Context_OpenInBrowser", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Failed to open the link in the browser, please report this issue. + /// + internal static string Context_OpenInBrowserError { + get { + return ResourceManager.GetString("Context_OpenInBrowserError", resourceCulture); + } + } + /// /// Looks up a localized string similar to Promote. /// @@ -1202,6 +1238,24 @@ namespace ChatTwo.Resources { } } + /// + /// Looks up a localized string similar to URL at {0}. + /// + internal static string Context_URLDomain { + get { + return ResourceManager.GetString("Context_URLDomain", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Only open URLs from websites you trust. + /// + internal static string Context_URLWarning { + get { + return ResourceManager.GetString("Context_URLWarning", resourceCulture); + } + } + /// /// Looks up a localized string similar to Chinese (full). /// diff --git a/ChatTwo/Resources/Language.resx b/ChatTwo/Resources/Language.resx index 4af8f8a..69cda28 100644 --- a/ChatTwo/Resources/Language.resx +++ b/ChatTwo/Resources/Language.resx @@ -871,4 +871,22 @@ Use this option if you experience cut-off tooltips. + + Copy link to clipboard + + + Copied link to clipboard + + + Open link in browser + + + Failed to open the link in the browser, please report this issue + + + URL at {0} + + + Only open URLs from websites you trust + diff --git a/ChatTwo/Store.cs b/ChatTwo/Store.cs index dda6102..5aea584 100755 --- a/ChatTwo/Store.cs +++ b/ChatTwo/Store.cs @@ -86,6 +86,11 @@ internal class Store : IDisposable { ["Type"] = new("PartyFinder"), ["Id"] = new(partyFinder.Id), }); + case URIPayload uri: + return new BsonDocument(new Dictionary { + ["Type"] = new("URI"), + ["Uri"] = new(uri.Uri.ToString()), + }); } return payload?.Encode(); @@ -99,6 +104,7 @@ internal class Store : IDisposable { return bson["Type"].AsString switch { "Achievement" => new AchievementPayload((uint) bson["Id"].AsInt64), "PartyFinder" => new PartyFinderPayload((uint) bson["Id"].AsInt64), + "URI" => new URIPayload(new Uri(bson["Uri"].AsString)), _ => null, }; } diff --git a/ChatTwo/Util/ChunkUtil.cs b/ChatTwo/Util/ChunkUtil.cs index 45368da..b4c01fc 100755 --- a/ChatTwo/Util/ChunkUtil.cs +++ b/ChatTwo/Util/ChunkUtil.cs @@ -1,6 +1,7 @@ using ChatTwo.Code; using Dalamud.Game.Text.SeStringHandling; using Dalamud.Game.Text.SeStringHandling.Payloads; +using System.Text; namespace ChatTwo.Util; @@ -100,6 +101,10 @@ internal static class ChunkUtil { var reader = new BinaryReader(new MemoryStream(rawPayload.Data[4..])); var id = GetInteger(reader); link = new AchievementPayload(id); + } else if (rawPayload.Data.Length > 5 && rawPayload.Data[1] == 0x27 && rawPayload.Data[3] == 0x07) { + // uri payload + var uri = new Uri(Encoding.UTF8.GetString(rawPayload.Data[4..])); + link = new URIPayload(uri); } else if (Equals(rawPayload, RawPayload.LinkTerminator)) { link = null; } diff --git a/ChatTwo/Util/ImGuiUtil.cs b/ChatTwo/Util/ImGuiUtil.cs index 0075556..31c4efc 100755 --- a/ChatTwo/Util/ImGuiUtil.cs +++ b/ChatTwo/Util/ImGuiUtil.cs @@ -197,16 +197,16 @@ internal static class ImGuiUtil { } } - internal static void WarningText(string text) { + internal static void WarningText(string text, bool wrap = true) { var style = StyleModel.GetConfiguredStyle() ?? StyleModel.GetFromCurrent(); var dalamudOrange = style.BuiltInColors?.DalamudOrange; if (dalamudOrange != null) { ImGui.PushStyleColor(ImGuiCol.Text, dalamudOrange.Value); } - ImGui.PushTextWrapPos(); + if (wrap) ImGui.PushTextWrapPos(); ImGui.TextUnformatted(text); - ImGui.PopTextWrapPos(); + if (wrap) ImGui.PopTextWrapPos(); if (dalamudOrange != null) { ImGui.PopStyleColor(); diff --git a/ChatTwo/Util/Payloads.cs b/ChatTwo/Util/Payloads.cs index a1d0a58..06d7028 100755 --- a/ChatTwo/Util/Payloads.cs +++ b/ChatTwo/Util/Payloads.cs @@ -37,3 +37,51 @@ internal class AchievementPayload : Payload { throw new NotImplementedException(); } } + + +internal class URIPayload(Uri uri) : Payload +{ + public override PayloadType Type => (PayloadType) 0x52; + + public Uri Uri { get; init; } = uri; + + private static readonly string[] ExpectedSchemes = ["http", "https"]; + private static readonly string DefaultScheme = "https"; + + /// + /// Create a URIPayload from a raw URI string. If the URI does not have a + /// scheme, it will default to https://. + /// + /// + /// If the URI is invalid, or if the scheme is not supported. + /// + public static URIPayload ResolveURI(string rawURI) + { + ArgumentNullException.ThrowIfNull(rawURI); + + // Check for expected scheme ://, if not add https:// + foreach (var scheme in ExpectedSchemes) + { + if (rawURI.StartsWith($"{scheme}://")) + { + return new URIPayload(new Uri(rawURI)); + } + } + if (rawURI.Contains("://")) + { + throw new UriFormatException($"Unsupported scheme in URL: {rawURI}"); + } + + return new URIPayload(new Uri($"{DefaultScheme}://{rawURI}")); + } + + protected override void DecodeImpl(BinaryReader reader, long endOfStream) + { + throw new NotImplementedException(); + } + + protected override byte[] EncodeImpl() + { + throw new NotImplementedException(); + } +} From 5da271e0c202f806f6e477e77a36a3f4af0c8ab2 Mon Sep 17 00:00:00 2001 From: Infi Date: Tue, 9 Apr 2024 15:58:53 +0200 Subject: [PATCH 2/2] - Fully support role colors and icons - Fix #5 - Add SeString debugger window --- ChatTwo/PayloadHandler.cs | 4 +- ChatTwo/Plugin.cs | 4 + ChatTwo/Store.cs | 2 + ChatTwo/Ui/ChatLogWindow.cs | 22 +-- ChatTwo/Ui/CommandHelpWindow.cs | 4 + ChatTwo/Ui/SeStringDebugger.cs | 319 ++++++++++++++++++++++++++++++++ ChatTwo/Util/ChunkUtil.cs | 18 +- ChatTwo/Util/ColourUtil.cs | 11 +- ChatTwo/Util/ExtraPayload.cs | 109 +++++++++++ ChatTwo/Util/IconUtil.cs | 235 ++++++++++++++--------- 10 files changed, 611 insertions(+), 117 deletions(-) create mode 100644 ChatTwo/Ui/SeStringDebugger.cs create mode 100644 ChatTwo/Util/ExtraPayload.cs diff --git a/ChatTwo/PayloadHandler.cs b/ChatTwo/PayloadHandler.cs index a971615..3980c44 100755 --- a/ChatTwo/PayloadHandler.cs +++ b/ChatTwo/PayloadHandler.cs @@ -102,9 +102,7 @@ public sealed class PayloadHandler { } var contentId = chunk.Message?.ContentId ?? 0; - var sender = chunk.Message?.Sender - .Select(chunk => chunk.Link) - .FirstOrDefault(chunk => chunk is PlayerPayload) as PlayerPayload; + var sender = chunk.Message?.Sender.Select(c => c.Link).FirstOrDefault(p => p is PlayerPayload) as PlayerPayload; if (ImGui.BeginMenu(Language.Context_Integrations)) { var cursor = ImGui.GetCursorPos(); diff --git a/ChatTwo/Plugin.cs b/ChatTwo/Plugin.cs index 45c1092..3a06b03 100755 --- a/ChatTwo/Plugin.cs +++ b/ChatTwo/Plugin.cs @@ -42,6 +42,7 @@ public sealed class Plugin : IDalamudPlugin { public SettingsWindow SettingsWindow { get; } public ChatLogWindow ChatLogWindow { get; } public CommandHelpWindow CommandHelpWindow { get; } + public SeStringDebugger SeStringDebugger { get; } internal Configuration Config { get; } internal Commands Commands { get; } @@ -81,10 +82,12 @@ public sealed class Plugin : IDalamudPlugin { ChatLogWindow = new ChatLogWindow(this); SettingsWindow = new SettingsWindow(this); CommandHelpWindow = new CommandHelpWindow(ChatLogWindow); + SeStringDebugger = new SeStringDebugger(this); WindowSystem.AddWindow(ChatLogWindow); WindowSystem.AddWindow(SettingsWindow); WindowSystem.AddWindow(CommandHelpWindow); + WindowSystem.AddWindow(SeStringDebugger); FontManager.BuildFonts(); Interface.UiBuilder.DisableCutsceneUiHide = true; @@ -114,6 +117,7 @@ public sealed class Plugin : IDalamudPlugin { WindowSystem.RemoveAllWindows(); ChatLogWindow.Dispose(); SettingsWindow.Dispose(); + SeStringDebugger.Dispose(); ExtraChat.Dispose(); Ipc.Dispose(); diff --git a/ChatTwo/Store.cs b/ChatTwo/Store.cs index dda6102..a493d2f 100755 --- a/ChatTwo/Store.cs +++ b/ChatTwo/Store.cs @@ -257,6 +257,7 @@ internal class Store : IDisposable { } } + public (SeString? Sender, SeString? Message) LastMessage = (null, null); private void ChatMessage(XivChatType type, uint senderId, SeString sender, SeString message) { var chatCode = new ChatCode((ushort) type); @@ -265,6 +266,7 @@ internal class Store : IDisposable { formatting = FormatFor(chatCode.Type); } + LastMessage = (sender, message); var senderChunks = new List(); if (formatting is { IsPresent: true }) { senderChunks.Add(new TextChunk(ChunkSource.None, null, formatting.Before) { diff --git a/ChatTwo/Ui/ChatLogWindow.cs b/ChatTwo/Ui/ChatLogWindow.cs index c5f73f6..193985b 100644 --- a/ChatTwo/Ui/ChatLogWindow.cs +++ b/ChatTwo/Ui/ChatLogWindow.cs @@ -75,12 +75,8 @@ public sealed class ChatLogWindow : Window, IUiComponent { Plugin = plugin; Salt = new Random().Next().ToString(); + Size = new Vector2(500, 250); SizeCondition = ImGuiCond.FirstUseEver; - SizeConstraints = new WindowSizeConstraints - { - MinimumSize = new Vector2(500, 250), - MaximumSize = new Vector2(float.MaxValue, float.MaxValue) - }; PayloadHandler = new PayloadHandler(this); HandlerLender = new Lender(() => new PayloadHandler(this)); @@ -1257,10 +1253,8 @@ public sealed class ChatLogWindow : Window, IUiComponent { || cmd.Alias.RawString == command || cmd.ShortCommand.RawString == command || cmd.ShortAlias.RawString == command); - if (cmd != null) { + if (cmd != null) Plugin.CommandHelpWindow.UpdateContent(cmd); - Plugin.CommandHelpWindow.IsOpen = true; - } } if (data->EventFlag != ImGuiInputTextFlags.CallbackHistory) { @@ -1332,15 +1326,15 @@ public sealed class ChatLogWindow : Window, IUiComponent { private void DrawChunk(Chunk chunk, bool wrap = true, PayloadHandler? handler = null, float lineWidth = 0f) { if (chunk is IconChunk icon && _fontIcon != null) { - var bounds = IconUtil.GetBounds((byte) icon.Icon); - if (bounds != null) { + var bounds = IconUtil.GfdFileView.TryGetEntry((uint) icon.Icon, out var entry); + if (bounds) { var texSize = new Vector2(_fontIcon.Width, _fontIcon.Height); - var sizeRatio = Plugin.Config.FontSize / bounds.Value.W; - var size = new Vector2(bounds.Value.Z, bounds.Value.W) * sizeRatio * ImGuiHelpers.GlobalScale; + var sizeRatio = Plugin.Config.FontSize / entry.Height; + var size = new Vector2(entry.Width, entry.Height) * sizeRatio * ImGuiHelpers.GlobalScale; - var uv0 = new Vector2(bounds.Value.X, bounds.Value.Y - 2) / texSize; - var uv1 = new Vector2(bounds.Value.X + bounds.Value.Z, bounds.Value.Y - 2 + bounds.Value.W) / texSize; + var uv0 = new Vector2(entry.Left, entry.Top) / texSize; + var uv1 = new Vector2(entry.Left + entry.Width, entry.Top + entry.Height) / texSize; ImGui.Image(_fontIcon.ImGuiHandle, size, uv0, uv1); ImGuiUtil.PostPayload(chunk, handler); } diff --git a/ChatTwo/Ui/CommandHelpWindow.cs b/ChatTwo/Ui/CommandHelpWindow.cs index ab9ac55..23590a3 100644 --- a/ChatTwo/Ui/CommandHelpWindow.cs +++ b/ChatTwo/Ui/CommandHelpWindow.cs @@ -20,6 +20,7 @@ public class CommandHelpWindow : Window { ImGuiWindowFlags.NoResize | ImGuiWindowFlags.NoFocusOnAppearing | ImGuiWindowFlags.AlwaysAutoResize; } + // Sets IsOpen to true if it should be drawn public void UpdateContent(TextCommand command) { Command = command; @@ -36,6 +37,7 @@ public class CommandHelpWindow : Window { break; case CommandHelpSide.None: default: + IsOpen = false; return; } @@ -45,6 +47,8 @@ public class CommandHelpWindow : Window { MinimumSize = new Vector2(width, 0), MaximumSize = LogWindow.LastWindowSize with { X = width } }; + + IsOpen = true; } public override void Draw() diff --git a/ChatTwo/Ui/SeStringDebugger.cs b/ChatTwo/Ui/SeStringDebugger.cs new file mode 100644 index 0000000..4bddcc3 --- /dev/null +++ b/ChatTwo/Ui/SeStringDebugger.cs @@ -0,0 +1,319 @@ +using System.Numerics; +using System.Text; +using ChatTwo.Util; +using Dalamud.Game.Text.SeStringHandling.Payloads; +using Dalamud.Interface.Windowing; +using ImGuiNET; +using DalamudPartyFinderPayload = Dalamud.Game.Text.SeStringHandling.Payloads.PartyFinderPayload; + +namespace ChatTwo.Ui; + +public class SeStringDebugger : Window +{ + private readonly Plugin Plugin; + + public SeStringDebugger(Plugin plugin) : base($"SeString Debugger###chat2-sestringdebugger") + { + Plugin = plugin; + + SizeConstraints = new WindowSizeConstraints + { + MinimumSize = new Vector2(475, 600), + MaximumSize = new Vector2(float.MaxValue, float.MaxValue) + }; + + Plugin.Commands.Register("/chat2Debugger").Execute += Toggle; + } + + public void Dispose() + { + Plugin.Commands.Register("/chat2Debugger").Execute -= Toggle; + } + + private void Toggle(string _, string __) => Toggle(); + + public override void Draw() + { + ImGui.TextUnformatted("SeString Content"); + ImGui.Spacing(); + + if (Plugin.Store.LastMessage.Sender == null) + { + ImGui.TextUnformatted("Nothing to show"); + return; + } + + // TODO: Make SeString freely selectable through chat + foreach (var payload in Plugin.Store.LastMessage.Sender.Payloads) + { + switch (payload) + { + case UIForegroundPayload color: + { + RenderMetadataDictionary("Link ForegroundColor", new Dictionary + { + { "Enabled?", color.IsEnabled.ToString() }, + { "ColorKey", color.IsEnabled ? color.ColorKey.ToString() : "Color Ended" }, + }); + break; + } + case MapLinkPayload map: + { + RenderMetadataDictionary("Link MapLinkPayload", new Dictionary + { + { "Map.RowId", map.Map?.RowId.ToString() }, + { "Map.PlaceName", map.Map?.PlaceName.Value?.Name.ToString() }, + { "Map.PlaceNameRegion", map.Map?.PlaceNameRegion.Value?.Name.ToString() }, + { "Map.PlaceNameSub", map.Map?.PlaceNameSub.Value?.Name.ToString() }, + { "TerritoryType.RowId", map.TerritoryType?.RowId.ToString() }, + { "RawX", map.RawX.ToString() }, + { "RawY", map.RawY.ToString() }, + { "XCoord", map.XCoord.ToString() }, + { "YCoord", map.YCoord.ToString() }, + { "CoordinateString", map.CoordinateString }, + { "DataString", map.DataString }, + }); + break; + } + case QuestPayload quest: + { + RenderMetadataDictionary("Link QuestPayload", new Dictionary + { + { "Quest.RowId", quest.Quest?.RowId.ToString() }, + { "Quest.Name", quest.Quest?.Name.ToString() }, + }); + break; + } + case DalamudLinkPayload link: + { + RenderMetadataDictionary("Link DalamudLinkPayload", new Dictionary + { + { "CommandId", link.CommandId.ToString() }, + { "Plugin", link.Plugin }, + }); + break; + } + case DalamudPartyFinderPayload pf: + { + RenderMetadataDictionary("Link PartyFinderPayload", new Dictionary + { + { "ListingId", pf.ListingId.ToString() }, + { "LinkType", EnumName(pf.LinkType) }, + }); + break; + } + case PlayerPayload player: + { + RenderMetadataDictionary("Link PlayerPayload", new Dictionary + { + { "Real", player.DisplayedName }, + { "PlayerName", player.PlayerName }, + { "World.Name", player.World.Name }, + }); + break; + } + case ItemPayload item: + { + RenderMetadataDictionary("Link ItemPayload", new Dictionary + { + { "ItemId", item.ItemId.ToString() }, + { "RawItemId", item.RawItemId.ToString() }, + { "Kind", EnumName(item.Kind) }, + { "IsHQ", item.IsHQ.ToString() }, + { "Item.Name", item.Item?.Name.ToString() }, + }); + break; + } + case AutoTranslatePayload at: + { + RenderMetadataDictionary("Link AutoTranslatePayload", new Dictionary + { + { "Text", at.Text }, + }); + break; + } + case IconPayload icon: + { + var found = IconUtil.GfdFileView.TryGetEntry((uint) icon.Icon, out var entry); + RenderMetadataDictionary("Link IconPayload", new Dictionary + { + { "Found", found.ToString() }, + { "Icon ID", ((uint) icon.Icon).ToString() }, + }); + break; + } + case RawPayload raw: + { + var colorPayload = ColorPayload.From(raw.Data); + if (colorPayload != null) + { + var push = colorPayload.Enabled && colorPayload.Color != 0; + // if (push) ImGui.PushStyleColor(ImGuiCol.Text, ColourUtil.RgbaToAbgr(colorPayload.U)); + RenderMetadataDictionary("Link ColorPayload", new Dictionary + { + { "Unshifted", colorPayload.UnshiftedColor.ToString("X8") }, + { "Color", colorPayload.Color.ToString("X8") }, + { "Enabled?", colorPayload.Enabled.ToString() }, + }); + // if (push) ImGui.PopStyleColor(); + } + else + { + RenderMetadataDictionary("Link RawPayload", new Dictionary + { + { "Data", string.Join(" ", raw.Data.Select(b => b.ToString("X2"))) }, + { "Type", EnumName(raw.Type) }, + }); + } + break; + } + case StatusPayload status: + { + RenderMetadataDictionary("Link StatusPayload", new Dictionary + { + { "Status.RowId", status.Status.RowId.ToString() }, + { "Status.Name", status.Status.Name }, + { "Status.Icon", status.Status.Icon.ToString() } + }); + break; + } + + case Util.PartyFinderPayload pf: + { + RenderMetadataDictionary("Link PartyFinderPayload", new Dictionary + { + { "Id", pf.Id.ToString() } + }); + break; + } + case AchievementPayload achievement: + { + RenderMetadataDictionary("Link AchievementPayload", new Dictionary + { + { "Id", achievement.Id.ToString() } + }); + break; + } + default: + var payloadData = payload.Encode(); + + var initialByte = payloadData.First(); + if (initialByte != 0x02) + { + RenderMetadataDictionary("Text Payload", new Dictionary + { + { "Content", Encoding.UTF8.GetString(payloadData) }, + }); + } + else + { + var unknown = new RawPayload(payloadData); + RenderMetadataDictionary("Link Unknown", new Dictionary + { + { "Unknown", string.Join(" ", unknown.Data.Select(b => b.ToString("X2"))) }, + }); + } + break; + } + } + } + + private static string? EnumName(T? value) where T : Enum + { + if (value == null) + { + return null; + } + var rawValue = Convert.ChangeType(value, value.GetTypeCode()); + return (Enum.GetName(value.GetType(), value) ?? "Unknown") + $" ({rawValue})"; + } + + private static void RenderMetadataDictionary(string name, Dictionary metadata) + { + var style = ImGui.GetStyle(); + + ImGui.Text($"{name}:"); + ImGui.Indent(style.IndentSpacing); + if (!ImGui.BeginTable($"##chat3-{name}", 2, 0)) + { + ImGui.EndTable(); + ImGui.Unindent(style.IndentSpacing); + return; + } + ImGui.TableSetupColumn($"##chat3-{name}-key", 0, 0.4f); + ImGui.TableSetupColumn($"##chat3-{name}-value"); + for (var i = 0; i < metadata.Count; i++) + { + var (key, value) = metadata.ElementAt(i); + ImGui.PushID(i); + ImGui.TableNextColumn(); + ImGui.Text(key); + ImGui.TableNextColumn(); + ImGuiTextVisibleWhitespace(value); + ImGui.PopID(); + } + ImGui.EndTable(); + ImGui.Unindent(style.IndentSpacing); + ImGui.NewLine(); + } + + // ImGuiTextVisibleWhitespace replaces leading and trailing whitespace with + // visible characters. The extra characters are rendered with a muted font. + private static void ImGuiTextVisibleWhitespace(string? original, bool wrap = true) + { + if (string.IsNullOrEmpty(original)) + { + var str = original == null ? "(null)" : "(empty)"; + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(1, 1, 1, 0.5f)); + ImGui.TextUnformatted(str); + ImGui.PopStyleColor(); + return; + } + + var text = original; + var start = 0; + var end = text.Length; + + ImGui.PushStyleVar(ImGuiStyleVar.ItemSpacing, new Vector2(0, 0)); + + void WriteText(string text) + { + if (wrap) + { + ImGui.TextWrapped(text); + } + else + { + ImGui.TextUnformatted(text); + } + } + + while (start < end && char.IsWhiteSpace(text[start])) + { + start++; + } + if (start > 0) + { + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(1, 1, 1, 0.5f)); + WriteText(new string('_', start)); + ImGui.PopStyleColor(); + ImGui.SameLine(); + } + + while (end > start && char.IsWhiteSpace(text[end - 1])) + { + end--; + } + + WriteText(text[start..end]); + if (end < text.Length) + { + ImGui.SameLine(); + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(1, 1, 1, 0.5f)); + WriteText(new string('_', text.Length - end)); + ImGui.PopStyleColor(); + } + + ImGui.PopStyleVar(); + } +} \ No newline at end of file diff --git a/ChatTwo/Util/ChunkUtil.cs b/ChatTwo/Util/ChunkUtil.cs index 45368da..ab045ea 100755 --- a/ChatTwo/Util/ChunkUtil.cs +++ b/ChatTwo/Util/ChunkUtil.cs @@ -68,15 +68,19 @@ internal static class ChunkUtil { break; case PayloadType.Unknown: var rawPayload = (RawPayload) payload; - if (rawPayload.Data.Length > 1 && rawPayload.Data[1] == 0x13) + var colorPayload = ColorPayload.From(rawPayload.Data); + if (colorPayload != null) { - if (foreground.Count > 0) { - foreground.Pop(); - } - else if (rawPayload.Data.Length > 6 && rawPayload.Data[2] == 0x05 && rawPayload.Data[3] == 0xF6) + if (colorPayload.Enabled) { - var (r, g, b) = (rawPayload.Data[4], rawPayload.Data[5], rawPayload.Data[6]); - foreground.Push(ColourUtil.ComponentsToRgba(r, g, b)); + if (colorPayload.Color > 0) + foreground.Push(colorPayload.Color); + else if (foreground.Count > 0) // Push the previous color as we don't want invisible text + foreground.Push(foreground.Peek()); + } + else if (foreground.Count > 0) + { + foreground.Pop(); } } else if (rawPayload.Data.Length > 1 && rawPayload.Data[1] == 0x14) diff --git a/ChatTwo/Util/ColourUtil.cs b/ChatTwo/Util/ColourUtil.cs index 0887e4c..145afe6 100755 --- a/ChatTwo/Util/ColourUtil.cs +++ b/ChatTwo/Util/ColourUtil.cs @@ -12,8 +12,8 @@ internal static class ColourUtil { } internal static uint RgbaToAbgr(uint rgba) { - var (r, g, b, a) = RgbaToComponents(rgba); - return (uint) ((a << 24) | (b << 16) | (g << 8) | r); + var tmp = ((rgba << 8) & 0xFF00FF00) | ((rgba >> 8) & 0xFF00FF); + return (tmp << 16) | (tmp >> 16); } internal static Vector3 RgbaToVector3(uint rgba) { @@ -38,6 +38,13 @@ internal static class ColourUtil { )); } + public static unsafe uint ArgbToRgba(uint x) + { + var buf = (byte*)&x; + (buf[1], buf[2], buf[3], buf[0]) = (buf[0], buf[1], buf[2], buf[3]); + return x; + } + internal static uint ComponentsToRgba(byte red, byte green, byte blue, byte alpha = 0xFF) => alpha | (uint) (red << 24) | (uint) (green << 16) diff --git a/ChatTwo/Util/ExtraPayload.cs b/ChatTwo/Util/ExtraPayload.cs new file mode 100644 index 0000000..9e736c9 --- /dev/null +++ b/ChatTwo/Util/ExtraPayload.cs @@ -0,0 +1,109 @@ +using Dalamud.Utility; +using FFXIVClientStructs.FFXIV.Client.UI.Misc; +using FFXIVClientStructs.FFXIV.Component.Text; + +namespace ChatTwo.Util; + +public class ColorPayload +{ + private const byte START_BYTE = 2; + + public bool Enabled; + public uint Color; + public uint UnshiftedColor; + + public static ColorPayload? From(byte[] data) + { + using var stream = new MemoryStream(data); + if (stream.ReadByte() != START_BYTE || stream.ReadByte() != 0x13) + return null; + + stream.ReadByte(); // skip the length byte; + + var typeByte = stream.ReadByte(); + var payload = new ColorPayload(); + if (typeByte == 0xEC) + { + payload.Enabled = false; + return payload; + } + + if (typeByte == 0xE9) + { + var param = stream.ReadByte(); + var ok = TryGetGNumDefault((uint) (param - 2), out var value); + if (!ok) + { + Plugin.Log.Error($"Unable to GetGNum for param {param - 2}"); + return null; + } + + payload.Enabled = true; + payload.UnshiftedColor = value; + payload.Color = ColourUtil.ArgbToRgba(value); + + return payload; + } + + if (typeByte is >= 0xF0 and <= 0xFE) + { + // From: https://github.com/NotAdam/Lumina/blob/master/src/Lumina/Text/Expressions/IntegerExpression.cs#L119-L128 + uint ShiftAndThrowIfZero(int v, int shift) + { + return v switch + { + -1 => throw new ArgumentException("Encountered premature end of input (unexpected EOF).", nameof(v)), + 0 => throw new ArgumentException("Encountered premature end of input (unexpected null character).", nameof(v)), + _ => (uint)v << shift + }; + } + + typeByte += 1; + var value = 0u; + if ((typeByte & 8) != 0) + value |= ShiftAndThrowIfZero(stream.ReadByte(), 24); + else + value |= 0xff000000u; + + if( (typeByte & 4) != 0 ) value |= ShiftAndThrowIfZero( stream.ReadByte(), 16 ); + if( (typeByte & 2) != 0 ) value |= ShiftAndThrowIfZero( stream.ReadByte(), 8 ); + if( (typeByte & 1) != 0 ) value |= ShiftAndThrowIfZero( stream.ReadByte(), 0 ); + + payload.Enabled = true; + payload.Color = ColourUtil.ArgbToRgba(value); + + return payload; + } + + return null; + } + + private static unsafe bool TryGetGNumDefault(uint parameterIndex, out uint value) + { + value = 0u; + + var rtm = RaptureTextModule.Instance(); + if (rtm is null) + return false; + + if (!ThreadSafety.IsMainThread) + { + Plugin.Log.Error("Global parameters may only be used from the main thread."); + return false; + } + + ref var gp = ref rtm->TextModule.MacroDecoder.GlobalParameters; + if (parameterIndex >= gp.MySize) + return false; + + var p = rtm->TextModule.MacroDecoder.GlobalParameters.Get(parameterIndex); + switch (p.Type) + { + case TextParameterType.Integer: + value = (uint)p.IntValue; + return true; + default: + return false; + } + } +} \ No newline at end of file diff --git a/ChatTwo/Util/IconUtil.cs b/ChatTwo/Util/IconUtil.cs index d4e68de..11cd426 100755 --- a/ChatTwo/Util/IconUtil.cs +++ b/ChatTwo/Util/IconUtil.cs @@ -1,95 +1,148 @@ -using System.Numerics; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; namespace ChatTwo.Util; -internal static class IconUtil { - internal static Vector4? GetBounds(byte id) => id switch { - 1 => new Vector4(0, 342, 40, 40), - 2 => new Vector4(40, 342, 40, 40), - 3 => new Vector4(80, 342, 40, 40), - 4 => new Vector4(120, 342, 40, 40), - 5 => new Vector4(160, 342, 40, 40), - 6 => new Vector4(0, 382, 40, 40), - 7 => new Vector4(40, 382, 40, 40), - 8 => new Vector4(80, 382, 40, 40), - 9 => new Vector4(120, 382, 40, 40), - 10 => new Vector4(160, 382, 40, 40), - 11 => new Vector4(0, 422, 40, 40), - 12 => new Vector4(40, 422, 40, 40), - 13 => new Vector4(80, 422, 40, 40), - 14 => new Vector4(120, 422, 40, 40), - 15 => new Vector4(160, 422, 40, 40), - 16 => new Vector4(120, 542, 40, 40), - 17 => new Vector4(160, 542, 40, 40), - 18 => new Vector4(0, 462, 108, 40), - 19 => new Vector4(108, 462, 108, 40), - 20 => new Vector4(120, 502, 40, 40), - 21 => new Vector4(0, 502, 56, 40), - 22 => new Vector4(56, 502, 64, 40), - 23 => new Vector4(160, 502, 40, 40), - 24 => new Vector4(0, 542, 56, 40), - 25 => new Vector4(56, 542, 64, 40), - 51 => new Vector4(248, 342, 40, 40), - 52 => new Vector4(288, 342, 40, 40), - 53 => new Vector4(328, 342, 40, 40), - 54 => new Vector4(200, 342, 24, 40), - 55 => new Vector4(224, 342, 24, 40), - 56 => new Vector4(200, 382, 40, 40), - 57 => new Vector4(240, 382, 40, 40), - 58 => new Vector4(280, 382, 40, 40), - 59 => new Vector4(200, 422, 40, 40), - 60 => new Vector4(240, 422, 40, 40), - 61 => new Vector4(280, 422, 40, 40), - 62 => new Vector4(320, 382, 40, 40), - 63 => new Vector4(320, 422, 40, 40), - 64 => new Vector4(368, 342, 40, 40), - 65 => new Vector4(408, 342, 40, 40), - 66 => new Vector4(448, 342, 40, 40), - 67 => new Vector4(360, 382, 40, 40), - 68 => new Vector4(400, 382, 40, 40), - 70 => new Vector4(360, 422, 40, 40), - 71 => new Vector4(400, 422, 40, 40), - 72 => new Vector4(440, 422, 40, 40), - 73 => new Vector4(440, 382, 40, 40), - 74 => new Vector4(216, 462, 40, 40), - 75 => new Vector4(256, 462, 40, 40), - 76 => new Vector4(296, 462, 40, 40), - 77 => new Vector4(336, 462, 40, 40), - 78 => new Vector4(376, 462, 40, 40), - 79 => new Vector4(416, 462, 40, 40), - 80 => new Vector4(456, 462, 40, 40), - 81 => new Vector4(200, 502, 40, 40), - 82 => new Vector4(240, 502, 40, 40), - 83 => new Vector4(280, 502, 40, 40), - 84 => new Vector4(320, 502, 40, 40), - 85 => new Vector4(360, 502, 40, 40), - 86 => new Vector4(400, 502, 40, 40), - 87 => new Vector4(440, 502, 40, 40), - 88 => new Vector4(200, 542, 40, 40), - 89 => new Vector4(240, 542, 40, 40), - 90 => new Vector4(280, 542, 40, 40), - 91 => new Vector4(320, 542, 40, 40), - 92 => new Vector4(360, 542, 40, 40), - 93 => new Vector4(400, 542, 40, 40), - 94 => new Vector4(440, 542, 40, 40), - 95 => new Vector4(0, 582, 40, 40), - 96 => new Vector4(40, 582, 40, 40), - 97 => new Vector4(80, 582, 40, 40), - 98 => new Vector4(120, 582, 40, 40), - 99 => new Vector4(160, 582, 40, 40), - 100 => new Vector4(200, 582, 40, 40), - 101 => new Vector4(240, 582, 40, 40), - 102 => new Vector4(280, 582, 40, 40), - 103 => new Vector4(320, 582, 40, 40), - 104 => new Vector4(360, 582, 40, 40), - 105 => new Vector4(400, 582, 40, 40), - 106 => new Vector4(440, 582, 40, 40), - 107 => new Vector4(0, 622, 40, 40), - 108 => new Vector4(40, 622, 40, 40), - 109 => new Vector4(80, 622, 40, 40), - 110 => new Vector4(120, 622, 40, 40), - 111 => new Vector4(160, 622, 40, 40), - 112 => new Vector4(200, 622, 40, 40), - _ => null, - }; +// From Kizer: https://github.com/Soreepeong/Dalamud/blob/feature/log-wordwrap/Dalamud/Interface/Spannables/Internal/GfdFileView.cs +public readonly unsafe ref struct GfdFileView +{ + private readonly ReadOnlySpan Span; + private readonly bool DirectLookup; + + /// Initializes a new instance of the struct. + /// The data. + public GfdFileView(ReadOnlySpan span) + { + Span = span; + if (span.Length < sizeof(GfdHeader)) + throw new InvalidDataException($"Not enough space for a {nameof(GfdHeader)}"); + if (span.Length < sizeof(GfdHeader) + (Header.Count * sizeof(GfdEntry))) + throw new InvalidDataException($"Not enough space for all the {nameof(GfdEntry)}"); + + var entries = Entries; + DirectLookup = true; + for (var i = 0; i < entries.Length && DirectLookup; i++) + DirectLookup &= i + 1 == entries[i].Id; + } + + /// Gets the header. + private ref readonly GfdHeader Header => ref MemoryMarshal.AsRef(Span); + + /// Gets the entries. + private ReadOnlySpan Entries => MemoryMarshal.Cast(Span[sizeof(GfdHeader)..]); + + /// Attempts to get an entry. + /// The icon ID. + /// The entry. + /// Whether to follow redirects. + /// true if found. + public bool TryGetEntry(uint iconId, out GfdEntry entry, bool followRedirect = true) + { + if (iconId == 0) + { + entry = default; + return false; + } + + var entries = Entries; + if (DirectLookup) + { + if (iconId <= entries.Length) + { + entry = entries[(int)(iconId - 1)]; + return !entry.IsEmpty; + } + + entry = default; + return false; + } + + var lo = 0; + var hi = entries.Length; + while (lo <= hi) + { + var i = lo + ((hi - lo) >> 1); + if (entries[i].Id == iconId) + { + if (followRedirect && entries[i].Redirect != 0) + { + iconId = entries[i].Redirect; + lo = 0; + hi = entries.Length; + continue; + } + + entry = entries[i]; + return !entry.IsEmpty; + } + + if (entries[i].Id < iconId) + lo = i + 1; + else + hi = i - 1; + } + + entry = default; + return false; + } + + /// Header of a .gfd file. + [StructLayout(LayoutKind.Sequential)] + public struct GfdHeader + { + /// Signature: "gftd0100". + public fixed byte Signature[8]; + + /// Number of entries. + public int Count; + + /// Unused/unknown. + public fixed byte Padding[4]; + } + + /// An entry of a .gfd file. + [StructLayout(LayoutKind.Sequential, Size = 0x10)] + public struct GfdEntry + { + /// ID of the entry. + public ushort Id; + + /// The left offset of the entry. + public ushort Left; + + /// The top offset of the entry. + public ushort Top; + + /// The width of the entry. + public ushort Width; + + /// The height of the entry. + public ushort Height; + + /// Unknown/unused. + public ushort Unk0A; + + /// The redirected entry, maybe. + public ushort Redirect; + + /// Unknown/unused. + public ushort Unk0E; + + /// Gets a value indicating whether this entry is effectively empty. + public bool IsEmpty => Width == 0 || Height == 0; + } +} + + + +internal static class IconUtil { + private static byte[]? GfdFile; + public static unsafe GfdFileView GfdFileView + { + get + { + GfdFile ??= Plugin.DataManager.GetFile("common/font/gfdata.gfd")!.Data; + return new GfdFileView(new ReadOnlySpan(Unsafe.AsPointer(ref GfdFile[0]), GfdFile.Length)); + } + } }