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.
393 lines
16 KiB
C#
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);
|
|
}
|
|
}
|