Files
HellionChat/HellionChat/Ui/Components/ChunkRenderer.cs
T
JonKazama-Hellion 16730d5ed9
Forge Announce / Post changelog to Hellion Forge (push) Successful in 9s
Security Scan (reusable) / Security Scan (push) Successful in 27s
Security / scan (push) Successful in 27s
Build / Build (Release) (push) Successful in 34s
Release / Build and attach release ZIP (push) Successful in 31s
chore(release): 2.0.2 -- BetterTTV out, placeholders fixed
BetterTTV emote support is removed rather than switched off. Its shared-emote
endpoint went behind authentication and that is where nearly all of them came
from; what was left is the 65-entry global set, eleven of those on the plugin's
own known-broken list, so 54 largely static images from Twitch's early days.
Exactly one is animated, and that one is 492 frames at 140x140 -- 37 MB of
texture memory for a single emote, uploaded one frame at a time. Not worth a
network call and an on-disk cache on every start.

Gone with it: the download path, the cache directory, the GIF renderer, the
settings section, the block list, and 13 translation keys across 50 resource
files. 1567 lines. The plugin now makes no outbound network calls at all, which
is the claim PRIVACY.md has always wanted to make without an asterisk.

EmotePayload and its MessagePack type byte stay, deliberately. Around a thousand
rows in a two-month-old database carry them, and dropping the type would make
those fail to deserialise -- the history is the one thing 2.0.0 promised not to
touch. Nothing writes one any more and a stored emote renders as the code that
was typed, which is what the sender saw when they typed it.

Five descriptions in the Window tab printed {0} where the plugin name belonged,
handed to the widget directly instead of through string.Format the way the rows
around them do. Every language was affected including English; German surfaced
it because the placeholder lands at the start of the sentence there. A test now
walks every placeholder-carrying resource string, finds its uses, and fails on
an unformatted one -- verified by putting the defect back and watching it go red.

New preview images. The old set was from 2026-05-08, older than every cycle in
2.0.0, and showed stacked ImGui defaults to anyone browsing the installer. Taken
with screenshot mode on so no character names reach a public repo. The wizard
takes the theme picker's slot.
2026-08-19 23:34:41 +02:00

205 lines
7.3 KiB
C#

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<ChunkRenderer> _logger;
private readonly GameFunctions.GameFunctions _gameFunctions;
private readonly string _salt;
public ChunkRenderer(
ThemeRegistry themes,
FontManager fonts,
ILogger<ChunkRenderer> 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<Chunk> 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}";
}
}