Files
HellionChat/HellionChat/Ui/Components/MessageList.cs
T
JonKazama-Hellion a3379818eb fix(popouts): the context menu never opened outside the main window
Right-clicking a name or an item inside a pop-out did nothing at all.

One payload handler is shared by the main window, every pop-out and the
input preview, and it holds a single popup state. The main window is
registered first, so it draws first, finds no open popup in its own scope,
reads that as "closed" and clears the state -- before the window that
actually opened the popup gets its turn.

The popup now belongs to the surface that opened it. The others leave its
state alone instead of dropping it. The rule itself sits in its own helper
because the handler pulls in Dalamud and cannot be loaded from a test.
2026-08-20 07:54:32 +02:00

618 lines
25 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;
// 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;
// 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, string?> _drawCompactRow;
private readonly Action<Message, string?> _drawCardRow;
// Reused across frames: at MessageManager.MessageDisplayLimit a fresh array
// per frame is 40 KB of garbage, and A later cycle put the default density on this
// path. The old comment named MaxLinesToRender and its 2500 default, a
// config field that had stopped bounding anything.
private float[] _heightScratch = [];
// Measured once per Draw rather than per row: it only moves when the clock
// format or the font does, and both of those are in the layout fingerprint.
private float _stampColumnWidth;
private bool _stampVisible;
private float _metaDrop;
// 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 (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: 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: 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).
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.
//
// v1.13.0 added four axes that could always stale this cache and never did:
// the italic size (pushed mid-row by ChunkRenderer), ItalicEnabled (which
// swaps between two differently sized faces), FontsEnabled and UseHellionFont
// (both swap the face outright, and their two size fields default to the same
// 12.75f -- so the fingerprint did not move while the glyph widths did).
private LayoutFingerprint BuildLayoutFingerprint(Tab tab, float contentWidth)
{
var fonts = _fonts.EffectiveFontFingerprint();
return new LayoutFingerprint(
fonts.Global,
fonts.Symbols,
fonts.Sender,
fonts.Meta,
fonts.Italic,
Plugin.Config.UseCompactDensity,
Plugin.Config.FontsEnabled,
Plugin.Config.UseHellionFont,
Plugin.Config.ItalicEnabled,
Plugin.Config.Use24HourClock,
tab.DisplayTimestamp,
(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(tab, 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;
}
// Claim the shared handler, so a popup opened here stays with this
// window instead of being cleared by whichever surface draws first.
if (_handler is not null)
_handler.ActiveSurface = this;
// 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;
MeasureTimestampColumn(tab);
// 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);
// 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();
}
// The stamp column is fixed width so sender names line up under each other.
// It stays reserved even when the stamp is hidden -- otherwise a per-tab
// switch would change every row height in the tab, and the height cache would
// need to carry the wrap position rather than just the format.
private void MeasureTimestampColumn(Tab tab)
{
_stampVisible = tab.DisplayTimestamp;
var meta = MetaFace();
float sample;
using (meta.Push())
sample = ImGui.CalcTextSize(TimestampColumn.SampleFor(Plugin.Config.Use24HourClock)).X;
_stampColumnWidth = sample + ImGui.CalcTextSize(" ").X * 2f;
// ImGui aligns a row by its top edge, so the smaller meta face would hang
// above the baseline of the body text beside it.
float bodyAscent;
using (BodyFace().Push())
bodyAscent = ImGui.GetFont().Ascent;
float metaAscent;
using (meta.Push())
metaAscent = ImGui.GetFont().Ascent;
_metaDrop = StyleEngine.BaselineMath.OffsetFor(
bodyAscent,
metaAscent,
StyleEngine.Metrics.Scale
);
}
// Both follow the same pair of settings every other push site follows.
private Dalamud.Interface.ManagedFontAtlas.IFontHandle BodyFace() =>
Plugin.Config.FontsEnabled || Plugin.Config.UseHellionFont
? _fonts.RegularFont!
: _fonts.Axis;
private Dalamud.Interface.ManagedFontAtlas.IFontHandle MetaFace() =>
Plugin.Config.FontsEnabled || Plugin.Config.UseHellionFont ? _fonts.MetaFont! : _fonts.Axis;
// Same size as the body face, drawn heavier. With the game font selected
// there is no heavier variant, so the sender leans on channel colour alone.
// From the mockup: the space between two messages in card density.
private const float CardGapRaw = 6f;
// The italic handle is optional -- the setting can disable it -- so this
// falls back to the game's own italic rather than to upright text.
private Dalamud.Interface.ManagedFontAtlas.IFontHandle ItalicFace() =>
Plugin.Config.FontsEnabled && _fonts.ItalicFont is not null
? _fonts.ItalicFont
: _fonts.AxisItalic;
private Dalamud.Interface.ManagedFontAtlas.IFontHandle SenderFace() =>
Plugin.Config.FontsEnabled || Plugin.Config.UseHellionFont
? _fonts.SenderFont!
: _fonts.Axis;
// Draws the stamp into its column and leaves the cursor at the text column,
// whether or not anything was drawn.
private void DrawTimestampCell(Message message, string? previousStamp)
{
var origin = ImGui.GetCursorPos();
var stamp = FormatTimestamp(message.Date);
var draw =
_stampVisible
&& RepeatedTimestamp.ShouldDraw(Plugin.Config.HideSameTimestamps, stamp, previousStamp);
if (draw)
{
ImGui.SetCursorPosY(origin.Y + _metaDrop);
using (MetaFace().Push())
ImGui.TextUnformatted(stamp);
ImGui.SameLine(0f, 0f);
}
ImGui.SetCursorPos(origin with { X = origin.X + _stampColumnWidth });
}
// 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, string? previousStamp)
{
// 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).
DrawTimestampCell(message, previousStamp);
if (message.Sender.Count == 0)
{
// Nobody said this -- it is the game talking. Italics carry that in
// every palette, which colour would not: the channel colours already
// in these chunks come from the game and are not ours to override.
using (ItalicFace().Push())
_chunkRenderer.DrawChunks(
message.Content,
wrap: true,
handler: _handler,
lineWidth: 0f
);
return;
}
using (SenderFace().Push())
_chunkRenderer.DrawChunks(message.Sender, wrap: true, handler: _handler, lineWidth: 0f);
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, string?> 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();
// 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(msg, heights[i]);
// From the data, not from a variable carried between rows: this loop
// starts at FirstVisible, so the row above the window was never drawn.
drawRow(msg, i > 0 ? FormatTimestamp(messages[i - 1].Date) : null);
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)
)
);
}
// 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(Message message, float height)
{
if (height <= 0f)
return;
var top = ImGui.GetCursorScreenPos();
PaintRowSurface(ImGui.GetWindowDrawList(), message, top, height, direct: true);
}
private void FillRowSurface(Message message, Vector2 top, float height)
{
if (height <= 0f)
return;
PaintRowSurface(ImGui.GetWindowDrawList(), message, top, height, direct: false);
}
private void PaintRowSurface(
ImDrawListPtr dl,
Message message,
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);
// Keyed on the message, not on where it happens to sit. A screen
// coordinate changes every frame while scrolling, so each row would get a
// fresh entry starting at zero and the highlight would never fade in at
// all. It would also let a row in the main window and one in a pop-out
// share an entry whenever their y and height matched.
var key = (uint)message.Id.GetHashCode();
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.
private void DrawLinearAndMeasure(
Guid tabId,
IReadOnlyList<Message> messages,
Action<Message, string?> 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();
for (var i = 0; i < messages.Count; i++)
{
var msg = messages[i];
var before = ImGui.GetCursorPosY();
var top = ImGui.GetCursorScreenPos();
drawRow(msg, i > 0 ? FormatTimestamp(messages[i - 1].Date) : null);
var after = ImGui.GetCursorPosY();
var height = after - before;
FillRowSurface(msg, top, height);
msg.Height[tabId] = height;
msg.IsVisible[tabId] = ImGui.IsItemVisible();
}
}
private void DrawCardRow(Message message, string? previousStamp)
{
// 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 (deferred to v1.9.0); plain
// text here.
// A system message has no sender, so a header row would be a stamp on a
// line of its own -- an empty gesture. Those stay single-line in both
// densities; only a message with a sender gets the two-line treatment.
if (message.Sender.Count == 0)
{
DrawTimestampCell(message, previousStamp);
using (ItalicFace().Push())
_chunkRenderer.DrawChunks(
message.Content,
wrap: true,
handler: _handler,
lineWidth: 0f
);
ImGui.Dummy(new Vector2(0f, CardGapRaw * StyleEngine.Metrics.Scale));
return;
}
DrawTimestampCell(message, previousStamp);
using (SenderFace().Push())
_chunkRenderer.DrawChunks(message.Sender, wrap: true, handler: _handler, lineWidth: 0f);
// Indented onto the text column so the body lines up under the name.
ImGui.Indent(_stampColumnWidth);
_chunkRenderer.DrawChunks(message.Content, wrap: true, handler: _handler, lineWidth: 0f);
ImGui.Unindent(_stampColumnWidth);
// Air between cards is what makes them read as cards. Measured into the
// row height, so the clipper plans against it.
ImGui.Dummy(new Vector2(0f, CardGapRaw * StyleEngine.Metrics.Scale));
}
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);
}
}