feat(chat): give each row a surface to sit on

A wash from the left at a tenth opacity, a two-pixel accent bar on the edge, both
fading in and out on the held hover value rather than snapping.

Two draw paths, because the height arrives at two different times. On a normal
frame the cached height is already correct -- a chat message does not change
height after its first measurement -- so the surface goes down before the text
and costs nothing. On the frame after the cache is dropped no row knows its
height yet, and that is the only time the draw-channel detour is needed. Without
it the entire list would flash bare for one frame after every window resize,
which is not rare: width and display scale are both in the fingerprint.

The gradient and the rounding cannot be one call. AddRectFilledMultiColor writes
four fixed vertices and takes no rounding parameter, so the rounded base goes
down first with the gradient inside it. At two pixels the bar has no visible
corners at all and needs neither.
This commit is contained in:
2026-08-19 11:47:46 +02:00
parent ba16ab59e3
commit 81e4c9367a
+77 -1
View File
@@ -414,6 +414,13 @@ internal sealed class MessageList
{
var msg = messages[i];
var before = ImGui.GetCursorPosY();
// The cached height is not an estimate here: a chat message does not
// change height after its first measurement, so last frame's value is
// this frame's value. That is what lets the surface go down before
// the text instead of needing a draw-channel detour.
DrawRowSurface(heights[i]);
drawRow(msg);
if (frozen)
continue;
@@ -432,6 +439,63 @@ internal sealed class MessageList
);
}
// Hover fill plus a 2px accent bar on the left edge. The gradient runs from
// the accent at a tenth opacity into nothing about seventy percent across,
// which is why it takes two rectangles: AddRectFilledMultiColor has no
// rounding parameter, so the rounded base goes down first and the gradient
// sits inside it.
private void DrawRowSurface(float height)
{
if (height <= 0f)
return;
var top = ImGui.GetCursorScreenPos();
PaintRowSurface(ImGui.GetWindowDrawList(), top, height, direct: true);
}
private void FillRowSurface(Vector2 top, float height)
{
if (height <= 0f)
return;
PaintRowSurface(ImGui.GetWindowDrawList(), top, height, direct: false);
}
private void PaintRowSurface(ImDrawListPtr dl, Vector2 top, float height, bool direct)
{
var scale = StyleEngine.Metrics.Scale;
var width = ImGui.GetContentRegionAvail().X;
if (width <= 0f)
return;
var min = top;
var max = top + new Vector2(width, height);
var hovered = ImGui.IsWindowHovered() && ImGui.IsMouseHoveringRect(min, max);
var key = (uint)HashCode.Combine(top.Y, height);
var amount = StyleEngine.HoverState.Query(key, hovered);
if (amount <= 0.01f)
return;
var theme = Plugin.Instance.ThemeRegistry.Active;
var accent = theme.Colors.Accent;
var rounding = 2f * scale;
var wash = ColourUtil.ApplyAlpha(ColourUtil.RgbaToAbgr(accent), 0.07f * amount);
if (direct)
dl.AddRectFilled(min, max, wash, rounding);
else
StyleEngine.RowSurfaceScope.Fill(min, max, wash, rounding);
// The bar is two pixels wide, so it has no visible corners to round.
var bar = ColourUtil.ApplyAlpha(ColourUtil.RgbaToAbgr(accent), amount);
var barMax = new Vector2(min.X + 2f * scale, max.Y);
if (direct)
dl.AddRectFilled(min, barMax, bar, 0f);
else
StyleEngine.RowSurfaceScope.Fill(min, barMax, bar, 0f);
}
// First-frame / post-invalidation fallback: draw + measure every row into the
// cache so the next frame can take the planned path. The settle gate on the
// layout fingerprint is what keeps a resize drag from landing here every frame.
@@ -441,12 +505,24 @@ internal sealed class MessageList
Action<Message> drawRow
)
{
// No row has a cached height on this frame, so the surface cannot be
// drawn ahead of the text. Channels let it go down afterwards and still
// land underneath. Without this the whole list would flash bare for one
// frame after every resize.
using var surfaces = StyleEngine.RowSurfaceScope.Push();
foreach (var msg in messages)
{
var before = ImGui.GetCursorPosY();
var top = ImGui.GetCursorScreenPos();
drawRow(msg);
var after = ImGui.GetCursorPosY();
msg.Height[tabId] = after - before;
var height = after - before;
FillRowSurface(top, height);
msg.Height[tabId] = height;
msg.IsVisible[tabId] = ImGui.IsItemVisible();
}
}