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:
@@ -129,6 +129,11 @@ internal static class PluginHostFactory
|
||||
sp.GetRequiredService<FontManager>(),
|
||||
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(
|
||||
sp.GetRequiredService<ILogger<Integrations.FailedTellNotifier>>()
|
||||
));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user