feat(style): a scope for drawing behind text that has not been measured yet

Needed on exactly one frame: the one after the height cache is dropped, when no
row knows its own height yet. Every other frame the cached height is already
right -- a chat message does not change height after its first measurement -- and
the caller paints the fill directly without coming near this.

Modelled on LightlessSync's SettingsCardScope, which had already worked out the
two things that make draw channels dangerous. Nesting a splitter into itself
asserts, and Dalamud does not compile asserts out, so the user gets an error
dialog rather than a glitch -- hence the depth count. And a forgotten merge is
not a dropped frame but a permanent one: the commands stay in the channel buffers
and never reach the draw list, then the next frame's split walks into the assert.
Hence the finally, by way of the struct's Dispose.

Fill switches to the background channel and switches straight back, so a caller
cannot leave the channel hanging even by returning early.
This commit is contained in:
2026-08-19 11:46:40 +02:00
parent 611dd368cb
commit ba16ab59e3
@@ -0,0 +1,75 @@
using System.Numerics;
using Dalamud.Bindings.ImGui;
namespace HellionChat.Ui.StyleEngine;
// Draws a surface behind a row whose height is only known after the text has been
// drawn. Text goes into channel 1, the fill into channel 0, and the merge puts
// the fill underneath.
//
// Only needed on the frame after the height cache is dropped. Every other frame
// the cached height is already correct -- a chat message does not change height
// after its first measurement -- so the caller draws the fill directly and never
// comes near this.
//
// Three things make draw channels sharp:
//
// Nesting a splitter into itself asserts, and Dalamud does not compile asserts
// out. The user would get an error dialog, not a glitch. Hence the depth count.
//
// A forgotten merge is not a dropped frame, it is permanent: the commands stay
// in the channel buffers and never reach the draw list, and the next frame's
// split walks into the assert. Hence the finally.
//
// And the clip rect carries over on a channel switch, so the fill inherits
// whatever was clipping the text.
internal static class RowSurfaceScope
{
[ThreadStatic]
private static int _depth;
[ThreadStatic]
private static ImDrawListPtr _drawList;
internal static bool IsActive => _depth > 0;
internal static Scope Push()
{
if (_depth == 0)
{
_drawList = ImGui.GetWindowDrawList();
_drawList.ChannelsSplit(2);
// Foreground is the resting state: everything that is not explicitly
// painting a surface draws where it expects to.
_drawList.ChannelsSetCurrent(1);
}
_depth++;
return new Scope();
}
// Paints into the background channel and returns immediately to the
// foreground, so a caller can never leave the channel switched.
internal static void Fill(Vector2 min, Vector2 max, uint abgr, float rounding)
{
if (!IsActive)
return;
_drawList.ChannelsSetCurrent(0);
_drawList.AddRectFilled(min, max, abgr, rounding);
_drawList.ChannelsSetCurrent(1);
}
internal readonly struct Scope : IDisposable
{
public void Dispose()
{
_depth--;
if (_depth > 0)
return;
_drawList.ChannelsMerge();
_drawList = default;
}
}
}