feat(messages): restore scroll-to-bottom bar with snap decision
This commit is contained in:
@@ -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() { }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user