From c28e3f72a1edf331479b58e5e506bb1936fb41d6 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Wed, 10 Jun 2026 16:55:26 +0200 Subject: [PATCH] feat(messages): restore scroll-to-bottom bar with snap decision --- HellionChat/Plugin.cs | 1 + .../SelfTests/ScrollSnapDecisionStep.cs | 59 +++++++++++++ HellionChat/Ui/Components/MessageList.cs | 83 ++++++++++++++++++- HellionChat/Ui/Windows/MainWindow.cs | 2 + 4 files changed, 143 insertions(+), 2 deletions(-) create mode 100644 HellionChat/SelfTests/ScrollSnapDecisionStep.cs diff --git a/HellionChat/Plugin.cs b/HellionChat/Plugin.cs index c4dc42b..f49a649 100755 --- a/HellionChat/Plugin.cs +++ b/HellionChat/Plugin.cs @@ -404,6 +404,7 @@ public sealed class Plugin : IAsyncDalamudPlugin new SelfTests.NotificationSoundSelectStep(), new SelfTests.SidebarGreetedGlyphStep(this), new SelfTests.SidebarSectionHeaderStep(this), + new SelfTests.ScrollSnapDecisionStep(this), ]); // Re-surface the wizard for existing users when a major UX diff --git a/HellionChat/SelfTests/ScrollSnapDecisionStep.cs b/HellionChat/SelfTests/ScrollSnapDecisionStep.cs new file mode 100644 index 0000000..54c3ce5 --- /dev/null +++ b/HellionChat/SelfTests/ScrollSnapDecisionStep.cs @@ -0,0 +1,59 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Plugin.SelfTest; + +namespace HellionChat.SelfTests; + +// B3-5: only the snap decision is headless-testable. Scroll detection + bar + +// hit-test are smoke-only (the scroll child exists only in-game; GetScrollY is +// garbage headless). Drives ResolveSnapToBottom via the SelfTest accessor and +// asserts the OR + the request reset invariant. +// Uses the mandatory RequestScrollToBottomForSelfTest() setter (added in Step 1) +// to flip _scrollToBottomRequested without a real click — REQUIRED for the reset +// invariant assert; without it only the OR branch is testable. +internal sealed class ScrollSnapDecisionStep : ISelfTestStep +{ + private readonly Plugin plugin; + + public ScrollSnapDecisionStep(Plugin plugin) => this.plugin = plugin; + + public string Name => "Hellion Chat - Scroll snap decision"; + + public SelfTestStepResult RunStep() + { + var messages = plugin.MainWindow.GetMessageListForSelfTest(); + if (messages is null) + { + ImGui.Text("MessageList null"); + return SelfTestStepResult.Fail; + } + + // Start-state hygiene: a real click this frame could leave a pending + // request behind. Drain it so the asserts below are order-independent. + // Acceptable side effect: the drained click is swallowed and its snap + // never happens — losing one click mid-selftest is irrelevant. + messages.ResolveSnapToBottom(false); + + if (!messages.ResolveSnapToBottom(true)) + { + ImGui.Text("pinnedToBottom=true must snap"); + return SelfTestStepResult.Fail; + } + + messages.RequestScrollToBottomForSelfTest(); + if (!messages.ResolveSnapToBottom(false)) + { + ImGui.Text("pending request must snap even when not pinned"); + return SelfTestStepResult.Fail; + } + + if (messages.ResolveSnapToBottom(false)) + { + ImGui.Text("request must be consumed by one snap (reset invariant)"); + return SelfTestStepResult.Fail; + } + + return SelfTestStepResult.Pass; + } + + public void CleanUp() { } +} diff --git a/HellionChat/Ui/Components/MessageList.cs b/HellionChat/Ui/Components/MessageList.cs index da9d3b7..a9c43ea 100644 --- a/HellionChat/Ui/Components/MessageList.cs +++ b/HellionChat/Ui/Components/MessageList.cs @@ -1,7 +1,8 @@ using System.Globalization; using System.Numerics; using Dalamud.Bindings.ImGui; -using Dalamud.Interface.Utility.Raii; +using Dalamud.Interface.Utility; +using HellionChat.Resources; using HellionChat.Util; namespace HellionChat.Ui.Components; @@ -20,6 +21,12 @@ internal sealed class MessageList 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; + // §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) @@ -33,6 +40,20 @@ internal sealed class MessageList _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; + public void Draw(Tab tab) { if (!_fonts.FontsReady) @@ -57,13 +78,71 @@ internal sealed class MessageList else DrawCard(tab, messages); - if (pinnedToBottom) + // 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 DrawCompact(IReadOnlyList messages) { unsafe diff --git a/HellionChat/Ui/Windows/MainWindow.cs b/HellionChat/Ui/Windows/MainWindow.cs index 8df6df8..e944860 100644 --- a/HellionChat/Ui/Windows/MainWindow.cs +++ b/HellionChat/Ui/Windows/MainWindow.cs @@ -125,6 +125,8 @@ internal sealed class MainWindow : Window internal Components.HonorificHeader GetHonorificHeaderForSelfTest() => _honorific; + internal Components.MessageList GetMessageListForSelfTest() => _messages; + // new-shadow on Window.Toggle so the open path also writes Config — // OnClose already covers the close path through the base behaviour. public new void Toggle()