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
+1
View File
@@ -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
@@ -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() { }
}
+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()