diff --git a/HellionChat/PluginHostFactory.cs b/HellionChat/PluginHostFactory.cs index 1a2bc62..f03c37a 100644 --- a/HellionChat/PluginHostFactory.cs +++ b/HellionChat/PluginHostFactory.cs @@ -129,6 +129,11 @@ internal static class PluginHostFactory sp.GetRequiredService(), sp.GetRequiredService>() )); + services.AddSingleton(sp => new Ui.Components.MessageList( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService() + )); services.AddSingleton(sp => new Integrations.FailedTellNotifier( sp.GetRequiredService>() )); diff --git a/HellionChat/Ui/Components/MessageList.cs b/HellionChat/Ui/Components/MessageList.cs new file mode 100644 index 0000000..1bb7c67 --- /dev/null +++ b/HellionChat/Ui/Components/MessageList.cs @@ -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 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 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); + } +}