feat(chunk-renderer): add DrawChunks + DrawChunk text-path (C3 stubs icon)

Resurrects v1.5.6 ChatLogWindow's DrawChunks/DrawChunk text-rendering
pipeline into the new ChunkRenderer Components-Layer class. Text-chunk
path is the full v1.5.6 migration (Plugin.Config.ScreenshotMode,
_themes.Active.Colors.TextPrimary, _fonts.ItalicFont/_fonts.AxisItalic
substitutions applied per §4.2/§4.5); icon-chunk dispatch in DrawChunk
is stubbed pending C3 (EmoteCache + DrawIcon path).

ImGuiUtil.WrapText is forward-stubbed in Util/ImGuiUtil.cs as a no-op
TextUnformatted wrapper — Sub-Task D will replace the body with the
full ~220-LOC word-wrap pipeline. ImGuiUtil.PostPayload is also
forward-stubbed (payload hover/click routing belongs to Sub-Task E).
Both stubs are the cleanest cut to keep DrawChunk's body faithful to
v1.5.6 and avoid temporary fallback paths inside ChunkRenderer.

PayloadHandler.cs is a minimal forward-stub class (Hover + Click stubs
only) required by the DrawChunks/DrawChunk and PostPayload signatures.
Sub-Task E will replace this stub with the full implementation.

Discard pattern from C1 removed for _themes/_fonts (now genuinely
consumed by DrawChunks/DrawChunk); _logger discard kept — not yet
consumed in C2, deferred to E-task wiring.
This commit is contained in:
2026-05-27 08:59:23 +02:00
parent 7e541ac842
commit dec0daf30c
3 changed files with 205 additions and 3 deletions
+15
View File
@@ -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) { }
}
+143 -3
View File
@@ -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.Themes;
using HellionChat.Util; using HellionChat.Util;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -27,12 +33,146 @@ internal sealed class ChunkRenderer
// names change every plugin reload to avoid stable cross-session linkage. // names change every plugin reload to avoid stable cross-session linkage.
_salt = new Random().Next().ToString(); _salt = new Random().Next().ToString();
// Field references kept for C2 consumption; remove no-ops when DrawChunks lands.
_ = _themes;
_ = _fonts;
_ = _logger; _ = _logger;
} }
public void DrawChunks(
IReadOnlyList<Chunk> 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) private string HidePlayerInString(string str, string playerName, uint worldId)
{ {
var expected = _gameFunctions.Chat.AbbreviatePlayerName(playerName); var expected = _gameFunctions.Chat.AbbreviatePlayerName(playerName);
+47
View File
@@ -615,4 +615,51 @@ internal static class ImGuiUtil
extraChatChannels.Remove(id); 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);
}
} }