diff --git a/HellionChat/PayloadHandler.cs b/HellionChat/PayloadHandler.cs new file mode 100644 index 0000000..dfb1bba --- /dev/null +++ b/HellionChat/PayloadHandler.cs @@ -0,0 +1,15 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Game.Text.SeStringHandling; + +namespace HellionChat; + +// TODO(E): full PayloadHandler implementation — click/hover routing, tooltip +// positioning, party-finder and URI handling. This forward-stub exists only to +// satisfy the DrawChunks/DrawChunk and ImGuiUtil.PostPayload signatures while +// Sub-Task E is pending. +public sealed class PayloadHandler +{ + internal void Hover(Payload payload) { } + + internal void Click(Chunk chunk, Payload? payload, ImGuiMouseButton button) { } +} diff --git a/HellionChat/Ui/Components/ChunkRenderer.cs b/HellionChat/Ui/Components/ChunkRenderer.cs index 9b3a8f7..89a501d 100644 --- a/HellionChat/Ui/Components/ChunkRenderer.cs +++ b/HellionChat/Ui/Components/ChunkRenderer.cs @@ -1,3 +1,9 @@ +using System.Collections.Generic; +using System.Numerics; +using Dalamud.Bindings.ImGui; +using Dalamud.Game.Text.SeStringHandling.Payloads; +using Dalamud.Interface.Utility.Raii; +using HellionChat.Code; using HellionChat.Themes; using HellionChat.Util; using Microsoft.Extensions.Logging; @@ -27,12 +33,146 @@ internal sealed class ChunkRenderer // names change every plugin reload to avoid stable cross-session linkage. _salt = new Random().Next().ToString(); - // Field references kept for C2 consumption; remove no-ops when DrawChunks lands. - _ = _themes; - _ = _fonts; _ = _logger; } + public void DrawChunks( + IReadOnlyList chunks, + bool wrap = true, + PayloadHandler? handler = null, + float lineWidth = 0f + ) + { + // UI-7: render a copy with the sender name reformatted per the user's + // display options. Skipped in screenshot mode so the name-anonymising + // path in DrawChunk stays reliable (privacy wins). ForDisplay returns + // the list unchanged when nothing applies, so non-sender lists and the + // neutral default cost only a quick scan. + if (!Plugin.Config.ScreenshotMode) + chunks = SenderNameDisplay.ForDisplay(chunks); + + using var style = ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, Vector2.Zero); + + for (var i = 0; i < chunks.Count; i++) + { + if (chunks[i] is TextChunk text && string.IsNullOrEmpty(text.Content)) + continue; + + DrawChunk(chunks[i], wrap, handler, lineWidth); + + if (i < chunks.Count - 1) + { + ImGui.SameLine(); + } + else if (chunks[i].Link is EmotePayload && Plugin.Config.ShowEmotes) + { + // Emote payloads seem to not automatically put newlines, which + // is an issue when modern mode is disabled. + ImGui.SameLine(); + // Use default ImGui behavior for newlines. + ImGui.TextUnformatted(""); + } + } + } + + private void DrawChunk( + Chunk chunk, + bool wrap = true, + PayloadHandler? handler = null, + float lineWidth = 0f + ) + { + if (chunk is IconChunk) + { + // TODO(C3): wire DrawIcon dispatch + EmotePayload image path here. + return; + } + + if (chunk is not TextChunk text) + return; + + if (chunk.Link is EmotePayload emotePayload && Plugin.Config.ShowEmotes) + { + var emoteSize = ImGui.CalcTextSize("W"); + emoteSize = emoteSize with { Y = emoteSize.X } * 1.5f; + + // TextWrap doesn't work for emotes, so we have to wrap them manually + if (ImGui.GetContentRegionAvail().X < emoteSize.X) + ImGui.NewLine(); + + // We only draw a dummy if it is still loading, in the case it failed we draw the actual name + var image = EmoteCache.GetEmote(emotePayload.Code); + if (image is { Failed: false }) + { + if (image.IsLoaded) + image.Draw(emoteSize); + else + ImGui.Dummy(emoteSize); + + if (ImGui.IsItemHovered()) + ImGuiUtil.Tooltip(emotePayload.Code); + + return; + } + } + + var colour = text.Foreground; + if (colour == null && text.FallbackColour != null) + { + var type = text.FallbackColour.Value; + colour = Plugin.Config.ChatColours.TryGetValue(type, out var col) + ? col + : type.DefaultColor(); + } + + var push = colour != null; + var uColor = push ? ColourUtil.RgbaToAbgr(colour!.Value) : 0; + using var pushedColor = ImRaii.PushColor(ImGuiCol.Text, uColor, push); + + var useCustomItalicFont = Plugin.Config.FontsEnabled && _fonts.ItalicFont != null; + if (text.Italic) + (useCustomItalicFont ? _fonts.ItalicFont! : _fonts.AxisItalic).Push(); + + // Check for contains here as sometimes there are multiple + // TextChunks with the same PlayerPayload but only one has the name. + // E.g. party chat with cross world players adds extra chunks. + // + // Note: This has been null before, I'm guessing due to some issues with + // other plugins. New TextChunks will now enforce empty string in ctor, + // but old ones may still be null. + // ReSharper disable once NullCoalescingConditionIsAlwaysNotNullAccordingToAPIContract + var content = text.Content ?? ""; + if (Plugin.Config.ScreenshotMode) + { + if (chunk.Link is PlayerPayload playerPayload) + content = HidePlayerInString( + content, + playerPayload.PlayerName, + playerPayload.World.RowId + ); + else if (Plugin.PlayerState.IsLoaded) + content = HidePlayerInString( + content, + Plugin.PlayerState.CharacterName, + Plugin.PlayerState.HomeWorld.RowId + ); + } + + var defaultText = ColourUtil.RgbaToVector4(_themes.Active.Colors.TextPrimary); + if (wrap) + { + ImGuiUtil.WrapText(content, chunk, handler, defaultText, lineWidth); + } + else + { + ImGui.TextUnformatted(content); + ImGuiUtil.PostPayload(chunk, handler); + } + + if (text.Italic) + (useCustomItalicFont ? _fonts.ItalicFont! : _fonts.AxisItalic).Pop(); + } + private string HidePlayerInString(string str, string playerName, uint worldId) { var expected = _gameFunctions.Chat.AbbreviatePlayerName(playerName); diff --git a/HellionChat/Util/ImGuiUtil.cs b/HellionChat/Util/ImGuiUtil.cs index b516c16..b791f58 100755 --- a/HellionChat/Util/ImGuiUtil.cs +++ b/HellionChat/Util/ImGuiUtil.cs @@ -615,4 +615,51 @@ internal static class ImGuiUtil extraChatChannels.Remove(id); } } + + // Payload interaction state shared between PostPayload and WrapText. + // Tracks the last hovered payload so hover-leave events can fire correctly. + private static readonly ImGuiMouseButton[] Buttons = + [ + ImGuiMouseButton.Left, + ImGuiMouseButton.Middle, + ImGuiMouseButton.Right, + ]; + + private static Payload? Hovered; + + internal static void PostPayload(Chunk chunk, PayloadHandler? handler) + { + var payload = chunk.Link; + if (payload != null && ImGui.IsItemHovered()) + { + Hovered = payload; + ImGui.SetMouseCursor(ImGuiMouseCursor.Hand); + handler?.Hover(payload); + } + else if (!ReferenceEquals(Hovered, payload)) + { + Hovered = null; + } + + if (handler == null) + return; + + foreach (var button in Buttons) + if (ImGui.IsItemClicked(button)) + handler.Click(chunk, payload, button); + } + + // TODO(D): real word-wrap pipeline (~220 LOC) lands in Sub-Task D. + // This forward-stub lets DrawChunk compile while keeping its body + // faithful to v1.5.6 without temporary fallback paths inside ChunkRenderer. + internal static void WrapText( + string csText, + Chunk chunk, + PayloadHandler? handler, + Vector4 defaultText, + float lineWidth + ) + { + ImGui.TextUnformatted(csText); + } }