feat(ui): add MessageList with two-mode virtualisation

Compact mode reuses ImGuiListClipper because rows are a constant line
height; card mode falls back to a linear render with a per-message height
cache and an IsItemVisible skip path so off-screen rows place a Dummy of
the cached height instead of running the full render. Bottom-lock detects
whether the user was pinned to the bottom before the layout pass and
re-pins after new rows land. Renders text-only via SeString.TextValue for
this cycle — full chunk and payload rendering re-attaches later, so the
component shape stays correct without dragging the v1.5.6 chunk pipeline
into the new layer.
This commit is contained in:
2026-05-23 18:53:38 +02:00
parent 6ab2e9cece
commit eb95968750
2 changed files with 152 additions and 0 deletions
+5
View File
@@ -129,6 +129,11 @@ internal static class PluginHostFactory
sp.GetRequiredService<FontManager>(), sp.GetRequiredService<FontManager>(),
sp.GetRequiredService<ILogger<Ui.Components.Sidebar>>() sp.GetRequiredService<ILogger<Ui.Components.Sidebar>>()
)); ));
services.AddSingleton(sp => new Ui.Components.MessageList(
sp.GetRequiredService<ThemeRegistry>(),
sp.GetRequiredService<Ui.StyleEngine.TokenResolver>(),
sp.GetRequiredService<FontManager>()
));
services.AddSingleton(sp => new Integrations.FailedTellNotifier( services.AddSingleton(sp => new Integrations.FailedTellNotifier(
sp.GetRequiredService<ILogger<Integrations.FailedTellNotifier>>() sp.GetRequiredService<ILogger<Integrations.FailedTellNotifier>>()
)); ));
+147
View File
@@ -0,0 +1,147 @@
using System.Globalization;
using System.Numerics;
using Dalamud.Bindings.ImGui;
using Dalamud.Interface.Utility.Raii;
using HellionChat.Themes;
using HellionChat.Ui.StyleEngine;
using HellionChat.Util;
namespace HellionChat.Ui.Components;
// Virtualised message list. Compact mode reuses ImGuiListClipper because
// rows have a constant line height; card mode falls back to a linear
// render with a per-message height cache and an IsItemVisible skip path
// so off-screen rows place a Dummy of the cached height rather than
// running the full render again. Text-only rendering for now — full
// chunk/payload rendering re-attaches in a later cycle.
internal sealed class MessageList
{
private const float CompactRowHeight = 18f;
private readonly ThemeRegistry _themes;
private readonly TokenResolver _resolver;
private readonly FontManager _fonts;
public MessageList(ThemeRegistry themes, TokenResolver resolver, FontManager fonts)
{
_themes = themes;
_resolver = resolver;
_fonts = fonts;
}
public void Draw(Tab tab)
{
if (!_fonts.FontsReady)
{
ImGui.TextUnformatted("Loading fonts…");
return;
}
using var child = ImRaii.Child("##hellion-messages", new Vector2(-1, -1));
if (!child.Success)
return;
var theme = _themes.Active;
var textAbgr = ColourUtil.RgbaToAbgr(theme.Colors.TextPrimary);
var mutedAbgr = ColourUtil.RgbaToAbgr(theme.Colors.TextMuted);
using var messages = tab.Messages.GetReadOnly(3);
var compact = Plugin.Config.UseCompactDensity;
// Track whether the user was pinned to the bottom before this frame
// so newly arriving rows do not yank them up — the standard
// chat-window expectation. Read the scroll state before drawing
// anything inside the child so the comparison is against the
// previous frame's max.
var pinnedToBottom = ImGui.GetScrollY() >= ImGui.GetScrollMaxY() - 1f;
if (compact)
DrawCompact(messages, textAbgr, mutedAbgr);
else
DrawCard(tab, messages, textAbgr, mutedAbgr);
if (pinnedToBottom)
ImGui.SetScrollHereY(1f);
}
private void DrawCompact(IReadOnlyList<Message> messages, uint textAbgr, uint mutedAbgr)
{
unsafe
{
var clipper = new ImGuiListClipperPtr(ImGuiNative.ImGuiListClipper());
try
{
clipper.Begin(messages.Count, CompactRowHeight);
while (clipper.Step())
{
for (var i = clipper.DisplayStart; i < clipper.DisplayEnd; i++)
DrawCompactRow(messages[i], textAbgr, mutedAbgr);
}
clipper.End();
}
finally
{
clipper.Destroy();
}
}
}
private void DrawCompactRow(Message message, uint textAbgr, uint mutedAbgr)
{
var timestamp = FormatTimestamp(message.Date);
var sender = message.SenderSource.TextValue;
var content = message.ContentSource.TextValue;
var line = string.IsNullOrEmpty(sender)
? $"{timestamp} {content}"
: $"{timestamp} {sender}: {content}";
ImGui.TextUnformatted(line);
}
private void DrawCard(Tab tab, IReadOnlyList<Message> messages, uint textAbgr, uint mutedAbgr)
{
var tabId = tab.Identifier;
for (var i = 0; i < messages.Count; i++)
{
var msg = messages[i];
// Cached row: place a Dummy of the known height and skip the
// full render path if the row is off-screen. Mirrors the
// v1.5.6 Card-Mode pattern in ChatLogWindow.DrawMessages.
msg.Height.TryGetValue(tabId, out var cachedHeight);
if (cachedHeight is float h)
{
var beforeDummy = ImGui.GetCursorPos();
ImGui.Dummy(new Vector2(10f, h));
var visible = ImGui.IsItemVisible();
msg.IsVisible[tabId] = visible;
if (!visible)
continue;
ImGui.SetCursorPos(beforeDummy);
}
var before = ImGui.GetCursorPosY();
DrawCardRow(msg);
var after = ImGui.GetCursorPosY();
msg.Height[tabId] = after - before;
}
}
private void DrawCardRow(Message message)
{
var timestamp = FormatTimestamp(message.Date);
var sender = message.SenderSource.TextValue;
var content = message.ContentSource.TextValue;
ImGui.TextUnformatted(string.IsNullOrEmpty(sender) ? timestamp : $"{timestamp} {sender}");
ImGui.PushTextWrapPos(0f);
ImGui.TextUnformatted(content);
ImGui.PopTextWrapPos();
}
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);
}
}