Files
HellionChat/HellionChat/Ui/Components/MessageList.cs
T
JonKazama-Hellion bfc61909cf refactor(ui): scale the remaining layout constants
What blocks A to E did not already touch: the honorific header height and its
two offsets, the message list dummy widths, and the quick-button reserve in the
input bar.

The reserve is the one with visible consequences. At 150% the buttons grow with
the font while a fixed 130px column does not, so they stopped fitting.

The honorific offsets are centred rather than scaled. The 8f there was
(30 - 14) / 2 for the old font, structurally the same case as the sidebar: a
scaled constant keeps its mis-centering, a computed one does not.
2026-08-18 00:05:23 +02:00

393 lines
16 KiB
C#

using System.Globalization;
using System.Linq;
using System.Numerics;
using Dalamud.Bindings.ImGui;
using Dalamud.Interface.Utility;
using HellionChat.Resources;
using HellionChat.Util;
namespace HellionChat.Ui.Components;
// Virtualised message list. Both densities wrap to arbitrary heights, so both
// run the same prefix-sum clipper (CardClipPlanner) over a per-message height
// cache, dropped whenever the layout fingerprint settles on a new value.
internal sealed class MessageList
{
private readonly FontManager _fonts;
private readonly ChunkRenderer _chunkRenderer;
private PayloadHandler? _handler;
// B3-5: scroll-to-bottom state. Per-instance, so pop-out windows (own
// MessageList instance, PluginHostFactory.cs:263-266) isolate automatically —
// the old 1.5.6 updateScrollState flag is NOT needed here.
private bool _scrolledUp;
private bool _scrollToBottomRequested;
// B2: the height cache is only valid while these inputs are unchanged.
// FontManager's own fingerprint covers font sizes only, not density / the two
// name-display modes / width — a stale height would misplace the clipper dummies.
// Per tab, not per list: the old single field let a width change in tab A mark
// itself applied, so tab B kept measuring against the previous width.
private readonly Dictionary<Guid, LayoutFingerprintGate> _fingerprintGates = [];
// Bound once. A method group off an instance method captures `this` and is
// not cached by Roslyn, so `compact ? DrawCompactRow : DrawCardRow` would
// allocate a delegate on every frame of every window.
private readonly Action<Message> _drawCompactRow;
private readonly Action<Message> _drawCardRow;
// Reused across frames: at the default MaxLinesToRender of 2500 a fresh
// array per frame is 10 KB of garbage, and A2 put the default density on
// this path.
private float[] _heightScratch = [];
// §6.2: setter-injection breaks the PayloadHandler → MainWindow → MessageList → PayloadHandler 3-cycle.
// Wired by PayloadHandlerInitHostedService.StartAsync after both singletons exist.
internal void AttachPayloadHandler(PayloadHandler handler)
{
_handler = handler;
}
public MessageList(FontManager fonts, ChunkRenderer chunkRenderer)
{
_drawCompactRow = DrawCompactRow;
_drawCardRow = DrawCardRow;
_fonts = fonts;
_chunkRenderer = chunkRenderer;
}
// Deterministic and ImGui-free: encapsulates the snap decision AND the
// request reset, so the reset invariant is covered. Called by the real Draw.
internal bool ResolveSnapToBottom(bool pinnedToBottom)
{
var snap = pinnedToBottom || _scrollToBottomRequested;
_scrollToBottomRequested = false;
return snap;
}
// SelfTest hook (B3-5 reset-invariant, REQUIRED — not optional). Lets
// ScrollSnapDecisionStep flip the request flag without a real click, so the
// post-snap reset can be asserted; without it only the OR branch is testable.
internal void RequestScrollToBottomForSelfTest() => _scrollToBottomRequested = true;
// SelfTest hook (B2): runs the real planner against a caller fixture so the
// step asserts the plan without a live scroll child (GetScrollY is garbage headless).
internal CardClipPlan PlanCardClipForSelfTest(
IReadOnlyList<float> heights,
float scrollY,
float viewportHeight
) => CardClipPlanner.Plan(heights, scrollY, viewportHeight);
// SelfTest hook (B2): drives the live invalidation, returns the tab's remaining
// cached-height count so the step can assert the drop. nowMs is a parameter so
// the step can step past the settle window without sleeping (v1.10.0/A1).
internal int RunHeightCacheInvalidationForSelfTest(Tab tab, float contentWidth, long nowMs)
{
InvalidateHeightCacheIfLayoutChanged(tab, contentWidth, nowMs);
using var messages = tab.Messages.GetReadOnly(3);
return messages.Count(m => m.Height.ContainsKey(tab.Identifier));
}
// Width is passed in (ContentRegionAvail is only valid inside the draw child);
// enum modes widened to int so the record stays comparable. UiScale is in here
// because it feeds CalcWordWrapPositionA -- a scale change rewraps every row.
private LayoutFingerprint BuildLayoutFingerprint(float contentWidth)
{
var (global, symbols) = _fonts.EffectiveFontFingerprint();
return new LayoutFingerprint(
global,
symbols,
Plugin.Config.UseCompactDensity,
(int)Plugin.Config.NameFormMode,
(int)Plugin.Config.WorldSuffixMode,
contentWidth,
ImGuiHelpers.GlobalScale
);
}
// Drop the tab's cached heights once the layout fingerprint has settled — one
// record compare per frame, a clear only after a real settings/resize change
// stopped moving. The gate is what keeps a slider drag from rebuilding the
// whole tab on every frame.
private void InvalidateHeightCacheIfLayoutChanged(Tab tab, float contentWidth, long nowMs)
{
if (!_fingerprintGates.TryGetValue(tab.Identifier, out var gate))
{
gate = new LayoutFingerprintGate();
_fingerprintGates[tab.Identifier] = gate;
}
if (!gate.ShouldInvalidate(BuildLayoutFingerprint(contentWidth), nowMs))
return;
using var messages = tab.Messages.GetReadOnly(3);
foreach (var msg in messages)
{
msg.Height.Remove(tab.Identifier);
msg.IsVisible.Remove(tab.Identifier);
}
}
public void Draw(Tab tab)
{
if (!_fonts.FontsReady)
{
ImGui.TextUnformatted("Loading fonts…");
return;
}
// No own ImRaii.Child here — MainWindow already wraps the message
// area in one. Nesting would give the window two stacked scrolls
// and a runaway content-height computation.
var compact = Plugin.Config.UseCompactDensity;
// B2: drop stale cached heights before the snapshot draw. Both densities
// need this now -- compact rows are not constant height either, they wrap.
// Width read here while it is valid.
InvalidateHeightCacheIfLayoutChanged(
tab,
ImGui.GetContentRegionAvail().X,
Environment.TickCount64
);
using var messages = tab.Messages.GetReadOnly(3);
// Track whether the user was pinned to the bottom before this frame
// so newly arriving rows do not yank them up. The check runs against
// the parent child's scroll state, which is the one MainWindow owns.
var pinnedToBottom = ImGui.GetScrollY() >= ImGui.GetScrollMaxY() - 1f;
// While the gate waits out a continuous change, measurements must not go
// back into the cache: the rows outside the viewport still carry the old
// geometry, and mixing the two makes the lead dummy drift every frame.
var frozen = _fingerprintGates[tab.Identifier].IsPending;
DrawRows(tab, messages, compact ? _drawCompactRow : _drawCardRow, frozen);
// B3-5: scroll values are frame-constant inside the child, so this
// reflects the current frame's state wherever it runs; kept after the
// render to mirror the 1.5.6 end-of-DrawMessageLog placement.
_scrolledUp = ImGui.GetScrollMaxY() - ImGui.GetScrollY() > 1f;
if (ResolveSnapToBottom(pinnedToBottom))
ImGui.SetScrollHereY(1f);
DrawScrollToBottomBar();
// OpenPopup in Click() and BeginPopup here share the ##hellion-main-area scope -> Popup-ID matches.
_handler?.Draw();
}
// B3-5: Discord-style full-width bar pinned to the bottom edge of the
// visible region while the user is scrolled up. Geometry comes from window
// pos + size (visible region), never from the content flow: when scrolled
// up the visible bottom sits above the content bottom, so the
// InvisibleButton stays inside the existing content rect and cannot grow
// GetScrollMaxY(). Drawn on the WINDOW drawlist so the enclosing child
// clips it; submitted after every payload chunk so the button wins the
// hit-test and PostPayload clicks underneath do not double-fire.
private void DrawScrollToBottomBar()
{
if (!_scrolledUp)
return;
var winPos = ImGui.GetWindowPos();
var winSize = ImGui.GetWindowSize();
var barHeight = ImGui.GetFrameHeight();
// The bar only renders while content overflows, so the vertical
// scrollbar is always up — keep the bar clear of it.
var barWidth = winSize.X - ImGui.GetStyle().ScrollbarSize;
var barTopLeft = new Vector2(winPos.X, winPos.Y + winSize.Y - barHeight);
var barBottomRight = barTopLeft + new Vector2(barWidth, barHeight);
var theme = Plugin.Instance.ThemeRegistry.Active;
var hovered = ImGui.IsMouseHoveringRect(barTopLeft, barBottomRight);
var fill = ColourUtil.RgbaToAbgr(
hovered ? theme.Colors.SurfaceHover : theme.Colors.Surface
);
var rounding = 4f * ImGuiHelpers.GlobalScale;
var dl = ImGui.GetWindowDrawList();
dl.AddRectFilled(barTopLeft, barBottomRight, fill, rounding);
dl.AddRect(
barTopLeft,
barBottomRight,
ColourUtil.RgbaToAbgr(theme.Colors.Border),
rounding
);
var label = HellionStrings.ChatLog_ScrollToBottom_Tooltip;
var textSize = ImGui.CalcTextSize(label);
var textPos =
barTopLeft + new Vector2((barWidth - textSize.X) / 2f, (barHeight - textSize.Y) / 2f);
dl.AddText(textPos, ColourUtil.RgbaToAbgr(theme.Colors.Accent), label);
// Click target after the visuals; nothing advances the cursor past the
// button, so content height is identical with and without the bar.
ImGui.SetCursorScreenPos(barTopLeft);
ImGui.InvisibleButton("##scroll-to-bottom-bar", new Vector2(barWidth, barHeight));
if (ImGui.IsItemClicked())
_scrollToBottomRequested = true;
}
private void DrawCompactRow(Message message)
{
// B2-1/B2-2: render the sender through DrawChunks (the name-aware path
// that applies WorldSuffixMode/NameFormMode via ForDisplay), not as a
// flat SenderSource.TextValue string. message.Sender already carries the
// channel brackets/colon as ChunkSource.None wrappers (MessageManager
// .cs:300-314), so the separator is rendered by the chunks. 1.5.6 parity
// (ChatLogWindow.cs:1965: DrawChunks(message.Sender) + SameLine).
var timestamp = FormatTimestamp(message.Date);
if (message.Sender.Count > 0)
{
ImGui.TextUnformatted($"{timestamp} ");
ImGui.SameLine(0f, 0f);
_chunkRenderer.DrawChunks(message.Sender, wrap: true, handler: _handler, lineWidth: 0f);
ImGui.SameLine(0f, 0f);
}
else
{
ImGui.TextUnformatted(timestamp);
ImGui.SameLine(0f, 0f);
}
_chunkRenderer.DrawChunks(message.Content, wrap: true, handler: _handler, lineWidth: 0f);
}
// Shared by both densities: compact rows wrap too, so neither has a constant
// height the ImGuiListClipper could work with. drawRow is the only difference.
private void DrawRows(
Tab tab,
IReadOnlyList<Message> messages,
Action<Message> drawRow,
bool frozen
)
{
var tabId = tab.Identifier;
var count = messages.Count;
if (count == 0)
return;
// A row with no cached height yet can't be planned (first frame / post-
// invalidation), so draw everything once to fill the cache, plan next frame.
if (_heightScratch.Length < count)
_heightScratch = new float[Math.Max(count, 256)];
var heights = _heightScratch;
var allCached = true;
for (var i = 0; i < count; i++)
{
if (messages[i].Height.TryGetValue(tabId, out var cached) && cached is float h)
heights[i] = h;
else
allCached = false;
}
if (!allCached)
{
// Always measures, frozen or not: without a filled cache there is
// nothing to plan against at all.
DrawLinearAndMeasure(tabId, messages, drawRow);
return;
}
var plan = CardClipPlanner.Plan(
new ArraySegment<float>(heights, 0, count),
ImGui.GetScrollY(),
ImGui.GetWindowSize().Y
);
// A dummy is submitted outside any style push, so it appends its own
// trailing ItemSpacing.y. Subtracting one spacing per dummy makes the dummy
// advance the cursor by exactly the planned height. (The measured row
// heights carry no trailing spacing: every row ends inside DrawChunks,
// which pushes ItemSpacing to zero, and ImGui writes the advance at item
// submission time.)
var spacingY = ImGui.GetStyle().ItemSpacing.Y;
float CompensatedDummy(float planned) => Math.Max(0f, planned - spacingY);
if (plan.FirstVisible < 0)
{
// Scrolled into a gap: one full-height dummy keeps the scrollbar honest.
var gap = CompensatedDummy(plan.LeadDummyHeight + plan.EndDummyHeight);
ImGui.Dummy(new Vector2(StyleEngine.Metrics.MessageDummyWidth, gap));
return;
}
if (plan.LeadDummyHeight > 0f)
ImGui.Dummy(
new Vector2(
StyleEngine.Metrics.MessageDummyWidth,
CompensatedDummy(plan.LeadDummyHeight)
)
);
for (var i = plan.FirstVisible; i <= plan.LastVisible; i++)
{
var msg = messages[i];
var before = ImGui.GetCursorPosY();
drawRow(msg);
if (frozen)
continue;
var after = ImGui.GetCursorPosY();
msg.Height[tabId] = after - before;
msg.IsVisible[tabId] = true;
}
if (plan.EndDummyHeight > 0f)
ImGui.Dummy(
new Vector2(
StyleEngine.Metrics.MessageDummyWidth,
CompensatedDummy(plan.EndDummyHeight)
)
);
}
// 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.
private void DrawLinearAndMeasure(
Guid tabId,
IReadOnlyList<Message> messages,
Action<Message> drawRow
)
{
foreach (var msg in messages)
{
var before = ImGui.GetCursorPosY();
drawRow(msg);
var after = ImGui.GetCursorPosY();
msg.Height[tabId] = after - before;
msg.IsVisible[tabId] = ImGui.IsItemVisible();
}
}
private void DrawCardRow(Message message)
{
// B2-1/B2-2: sender via DrawChunks (name-aware path), on its own line
// with content below — 1.5.6 card parity (ChatLogWindow.cs:1913, no
// SameLine after the sender). The 1.5.6 channel-colour push on the
// sender is deferred styling polish (masterplan §6 -> v1.9.0); plain
// text here.
var timestamp = FormatTimestamp(message.Date);
if (message.Sender.Count > 0)
{
ImGui.TextUnformatted($"{timestamp} ");
ImGui.SameLine(0f, 0f);
_chunkRenderer.DrawChunks(message.Sender, wrap: true, handler: _handler, lineWidth: 0f);
}
else
{
ImGui.TextUnformatted(timestamp);
}
_chunkRenderer.DrawChunks(message.Content, wrap: true, handler: _handler, lineWidth: 0f);
}
private static string FormatTimestamp(DateTimeOffset date)
{
var local = date.ToLocalTime();
return Plugin.Config.Use24HourClock
? local.ToString("HH:mm", CultureInfo.InvariantCulture)
: local.ToString("h:mm tt", CultureInfo.InvariantCulture);
}
}