feat(imgui-util): resurrect WrapText pipeline (~220 LOC from v1.5.6)

Replaces C2's no-op WrapText stub with the full word-wrap pipeline
(WrapText / WrapEncodedLine / CalcWordWrap / DrawText / FindFirstSpace).
ChunkRenderer.DrawChunk's text-path now renders properly wrapped text
with payload hover-highlights and click-binding via PostPayload (which
was already full-ported in C2).

Also adds LastLink and PayloadBounds static fields that C2's PostPayload
port required but did not declare; DrawText needs both for per-segment
hover-rectangle accumulation across wrapped lines.

Unblocks E5's Hover paths that depend on functional WrapText for
status/item tooltip rendering. No structural changes — pure body
migration of the v1.5.6 unsafe word-wrap implementation.
This commit is contained in:
2026-05-27 10:27:58 +02:00
parent 2067a54467
commit e46b6a7520
+187 -4
View File
@@ -626,6 +626,8 @@ internal static class ImGuiUtil
]; ];
private static Payload? Hovered; private static Payload? Hovered;
private static Payload? LastLink;
private static readonly List<(Vector2, Vector2)> PayloadBounds = [];
internal static void PostPayload(Chunk chunk, PayloadHandler? handler) internal static void PostPayload(Chunk chunk, PayloadHandler? handler)
{ {
@@ -649,9 +651,13 @@ internal static class ImGuiUtil
handler.Click(chunk, payload, button); handler.Click(chunk, payload, button);
} }
// TODO(D): real word-wrap pipeline (~220 LOC) lands in Sub-Task D. // Ceiling on the byte buffer for a single rendered line. UTF-8 takes at
// This forward-stub lets DrawChunk compile while keeping its body // most 4 bytes per char; ImGui's internal ImString limit is well below
// faithful to v1.5.6 without temporary fallback paths inside ChunkRenderer. // this and FFXIV's chat lines top out around a few hundred chars in
// practice. The cap prevents an unbounded ArrayPool rent if a caller
// ever feeds in a degenerate input.
private const int MaxLineByteCount = 16 * 1024;
internal static void WrapText( internal static void WrapText(
string csText, string csText,
Chunk chunk, Chunk chunk,
@@ -660,6 +666,183 @@ internal static class ImGuiUtil
float lineWidth float lineWidth
) )
{ {
ImGui.TextUnformatted(csText); if (csText.Length == 0)
return;
foreach (var part in csText.Split(["\r\n", "\r", "\n"], StringSplitOptions.None))
{
if (part.Length == 0)
{
ImGui.TextUnformatted("");
continue;
}
// Allocate against the encoder's own MaxByteCount so the buffer
// we hand to ImGui is sized by us. The actual byte count
// returned by GetBytes is then validated against that ceiling
// before any pointer arithmetic touches it; CodeQL recognises
// that comparison as a sanitiser for the
// cs/unvalidated-local-pointer-arithmetic taint flow.
var maxBytes = Encoding.UTF8.GetMaxByteCount(part.Length);
if (maxBytes <= 0 || maxBytes > MaxLineByteCount)
{
ImGui.TextUnformatted("");
continue;
}
var buffer = ArrayPool<byte>.Shared.Rent(maxBytes);
try
{
var written = Encoding.UTF8.GetBytes(part, 0, part.Length, buffer, 0);
if (written <= 0 || written > maxBytes)
{
ImGui.TextUnformatted("");
continue;
}
WrapEncodedLine(buffer.AsSpan(0, written), chunk, handler, defaultText, lineWidth);
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
}
private static unsafe void WrapEncodedLine(
ReadOnlySpan<byte> bytes,
Chunk chunk,
PayloadHandler? handler,
Vector4 defaultText,
float lineWidth
)
{
var byteCount = bytes.Length;
if (byteCount == 0)
{
ImGui.TextUnformatted("");
return;
}
fixed (byte* basePtr = bytes)
{
var widthLeft = ImGui.GetContentRegionAvail().X;
var endPrev = CalcWordWrap(basePtr, 0, byteCount, widthLeft);
if (endPrev < 0)
return;
var firstSpace = FindFirstSpace(bytes, 0, byteCount);
var properBreak = firstSpace <= endPrev;
if (properBreak)
{
DrawText(basePtr, 0, endPrev, chunk, handler, defaultText);
}
else if (lineWidth == 0f)
{
ImGui.TextUnformatted("");
}
else
{
// Check whether the next chunk would wrap at or past the
// first space. If yes, force a line break.
var wrapPos = CalcWordWrap(basePtr, 0, firstSpace, lineWidth);
if (wrapPos >= firstSpace)
ImGui.TextUnformatted("");
}
widthLeft = ImGui.GetContentRegionAvail().X;
var lineStart = 0;
while (endPrev < byteCount)
{
if (properBreak)
lineStart = endPrev;
// Skip a leading space at the start of a wrapped line.
if (lineStart < byteCount && bytes[lineStart] == (byte)' ')
lineStart++;
var newEnd = CalcWordWrap(basePtr, lineStart, byteCount, widthLeft);
if (properBreak && newEnd == endPrev)
break;
if (newEnd < 0)
{
ImGui.TextUnformatted("");
ImGui.TextUnformatted("");
break;
}
endPrev = newEnd;
DrawText(basePtr, lineStart, endPrev, chunk, handler, defaultText);
if (!properBreak)
{
properBreak = true;
widthLeft = ImGui.GetContentRegionAvail().X;
}
}
}
}
private static unsafe int CalcWordWrap(byte* basePtr, int start, int end, float width)
{
var result = ImGuiNative.CalcWordWrapPositionA(
ImGui.GetFont().Handle,
ImGuiHelpers.GlobalScale,
basePtr + start,
basePtr + end,
width
);
if (result == null)
return -1;
return (int)(result - basePtr);
}
private static unsafe void DrawText(
byte* basePtr,
int start,
int end,
Chunk chunk,
PayloadHandler? handler,
Vector4 defaultText
)
{
var oldPos = ImGui.GetCursorScreenPos();
ImGuiNative.TextUnformatted(basePtr + start, basePtr + end);
PostPayload(chunk, handler);
if (!ReferenceEquals(LastLink, chunk.Link))
PayloadBounds.Clear();
LastLink = chunk.Link;
if (Hovered != null && ReferenceEquals(Hovered, chunk.Link))
{
defaultText.W = 0.25f;
var actualCol = ColourUtil.Vector4ToAbgr(defaultText);
ImGui
.GetWindowDrawList()
.AddRectFilled(oldPos, oldPos + ImGui.GetItemRectSize(), actualCol);
foreach (var (boundsStart, boundsSize) in PayloadBounds)
ImGui
.GetWindowDrawList()
.AddRectFilled(boundsStart, boundsStart + boundsSize, actualCol);
PayloadBounds.Clear();
}
if (Hovered == null && chunk.Link != null)
PayloadBounds.Add((oldPos, ImGui.GetItemRectSize()));
}
private static int FindFirstSpace(ReadOnlySpan<byte> bytes, int start, int end)
{
for (var i = start; i < end; i++)
if (char.IsWhiteSpace((char)bytes[i]))
return i;
return end;
} }
} }