feat(messages): restore scroll-to-bottom bar with snap decision

This commit is contained in:
2026-06-10 16:55:26 +02:00
parent dc51e295ea
commit c28e3f72a1
4 changed files with 143 additions and 2 deletions
+81 -2
View File
@@ -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<Message> messages)
{
unsafe
+2
View File
@@ -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()