diff --git a/HellionChat/Util/ImGuiUtil.cs b/HellionChat/Util/ImGuiUtil.cs index b791f58..278d2cb 100755 --- a/HellionChat/Util/ImGuiUtil.cs +++ b/HellionChat/Util/ImGuiUtil.cs @@ -626,6 +626,8 @@ internal static class ImGuiUtil ]; private static Payload? Hovered; + private static Payload? LastLink; + private static readonly List<(Vector2, Vector2)> PayloadBounds = []; internal static void PostPayload(Chunk chunk, PayloadHandler? handler) { @@ -649,9 +651,13 @@ internal static class ImGuiUtil 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. + // Ceiling on the byte buffer for a single rendered line. UTF-8 takes at + // most 4 bytes per char; ImGui's internal ImString limit is well below + // 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( string csText, Chunk chunk, @@ -660,6 +666,183 @@ internal static class ImGuiUtil 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.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.Shared.Return(buffer); + } + } + } + + private static unsafe void WrapEncodedLine( + ReadOnlySpan 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 bytes, int start, int end) + { + for (var i = start; i < end; i++) + if (char.IsWhiteSpace((char)bytes[i])) + return i; + + return end; } }