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<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>>()
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -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