using System.Collections.Generic; using System.Numerics; using Dalamud.Bindings.ImGui; using Dalamud.Game.Text.SeStringHandling.Payloads; using Dalamud.Interface.Utility; using Dalamud.Interface.Utility.Raii; using HellionChat.Code; using HellionChat.Themes; using HellionChat.Util; using Microsoft.Extensions.Logging; namespace HellionChat.Ui.Components; internal sealed class ChunkRenderer { private readonly ThemeRegistry _themes; private readonly FontManager _fonts; private readonly ILogger _logger; private readonly GameFunctions.GameFunctions _gameFunctions; private readonly string _salt; public ChunkRenderer( ThemeRegistry themes, FontManager fonts, ILogger logger, GameFunctions.GameFunctions gameFunctions ) { _themes = themes; _fonts = fonts; _logger = logger; _gameFunctions = gameFunctions; // Per-ctor random matches v1.5.6 ChatLogWindow behavior — hashed player // names change every plugin reload to avoid stable cross-session linkage. _salt = new Random().Next().ToString(); // No call sites yet; logging here will likely come later. _ = _logger; } // render-observability: the formatted sender text the real draw // path actually produced (post-ForDisplay). A SelfTest reads this after // driving DrawChunks to prove the WorldSuffixMode/NameFormMode reformat // reached the real render entry — never the helper in isolation. null until // a sender span is reformatted for display. internal string? LastRenderedSenderText { get; private set; } public void DrawChunks( IReadOnlyList chunks, bool wrap = true, PayloadHandler? handler = null, float lineWidth = 0f ) { // 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) { var displayed = SenderNameDisplay.ForDisplay(chunks); // ForDisplay only allocates a NEW list when it actually reformatted // a sender span (same reference on the neutral default / non-sender // lists). So this scan runs only when a sender name was reformatted // for display — zero overhead on the neutral-default hot path. if (!ReferenceEquals(displayed, chunks)) { chunks = displayed; foreach (var c in chunks) { if (c.Source == ChunkSource.Sender && c is TextChunk reformatted) { LastRenderedSenderText = reformatted.Content; break; } } } } 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(); } } private void DrawChunk( Chunk chunk, bool wrap = true, PayloadHandler? handler = null, float lineWidth = 0f ) { if (chunk is IconChunk iconChunk) { DrawIcon(chunk, iconChunk, handler); return; } if (chunk is not TextChunk text) 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(); } internal void DrawIcon(Chunk chunk, IconChunk icon, PayloadHandler? handler) { if (!IconUtil.GfdFileView.TryGetEntry((uint)icon.Icon, out var entry)) return; var iconTexture = Plugin .TextureProvider.GetFromGame("common/font/fonticon_ps5.tex") .GetWrapOrDefault(); if (iconTexture == null) return; var texSize = new Vector2(iconTexture.Width, iconTexture.Height); var sizeRatio = FontManager.GetFontSize() / entry.Height; var size = new Vector2(entry.Width, entry.Height) * sizeRatio * ImGuiHelpers.GlobalScale; var uv0 = new Vector2(entry.Left, entry.Top + 170) * 2 / texSize; var uv1 = new Vector2(entry.Left + entry.Width, entry.Top + entry.Height + 170) * 2 / texSize; ImGui.Image(iconTexture.Handle, size, uv0, uv1); ImGuiUtil.PostPayload(chunk, handler); } private string HidePlayerInString(string str, string playerName, uint worldId) { var expected = _gameFunctions.Chat.AbbreviatePlayerName(playerName); var hash = HashPlayer(playerName, worldId); return str.Replace(playerName, expected).Replace(expected, hash); } private string HashPlayer(string playerName, uint worldId) { var hashCode = $"{_salt}{playerName}{worldId}".GetHashCode(); return $"Player {hashCode:X8}"; } }