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.NotificationSoundSelectStep(),
|
||||||
new SelfTests.SidebarGreetedGlyphStep(this),
|
new SelfTests.SidebarGreetedGlyphStep(this),
|
||||||
new SelfTests.SidebarSectionHeaderStep(this),
|
new SelfTests.SidebarSectionHeaderStep(this),
|
||||||
|
new SelfTests.ScrollSnapDecisionStep(this),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Re-surface the wizard for existing users when a major UX
|
// 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.Globalization;
|
||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
using Dalamud.Bindings.ImGui;
|
using Dalamud.Bindings.ImGui;
|
||||||
using Dalamud.Interface.Utility.Raii;
|
using Dalamud.Interface.Utility;
|
||||||
|
using HellionChat.Resources;
|
||||||
using HellionChat.Util;
|
using HellionChat.Util;
|
||||||
|
|
||||||
namespace HellionChat.Ui.Components;
|
namespace HellionChat.Ui.Components;
|
||||||
@@ -20,6 +21,12 @@ internal sealed class MessageList
|
|||||||
|
|
||||||
private PayloadHandler? _handler;
|
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.
|
// §6.2: setter-injection breaks the PayloadHandler → MainWindow → MessageList → PayloadHandler 3-cycle.
|
||||||
// Wired by PayloadHandlerInitHostedService.StartAsync after both singletons exist.
|
// Wired by PayloadHandlerInitHostedService.StartAsync after both singletons exist.
|
||||||
internal void AttachPayloadHandler(PayloadHandler handler)
|
internal void AttachPayloadHandler(PayloadHandler handler)
|
||||||
@@ -33,6 +40,20 @@ internal sealed class MessageList
|
|||||||
_chunkRenderer = chunkRenderer;
|
_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)
|
public void Draw(Tab tab)
|
||||||
{
|
{
|
||||||
if (!_fonts.FontsReady)
|
if (!_fonts.FontsReady)
|
||||||
@@ -57,13 +78,71 @@ internal sealed class MessageList
|
|||||||
else
|
else
|
||||||
DrawCard(tab, messages);
|
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);
|
ImGui.SetScrollHereY(1f);
|
||||||
|
|
||||||
|
DrawScrollToBottomBar();
|
||||||
|
|
||||||
// OpenPopup in Click() and BeginPopup here share the ##hellion-main-area scope -> Popup-ID matches.
|
// OpenPopup in Click() and BeginPopup here share the ##hellion-main-area scope -> Popup-ID matches.
|
||||||
_handler?.Draw();
|
_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)
|
private void DrawCompact(IReadOnlyList<Message> messages)
|
||||||
{
|
{
|
||||||
unsafe
|
unsafe
|
||||||
|
|||||||
@@ -125,6 +125,8 @@ internal sealed class MainWindow : Window
|
|||||||
|
|
||||||
internal Components.HonorificHeader GetHonorificHeaderForSelfTest() => _honorific;
|
internal Components.HonorificHeader GetHonorificHeaderForSelfTest() => _honorific;
|
||||||
|
|
||||||
|
internal Components.MessageList GetMessageListForSelfTest() => _messages;
|
||||||
|
|
||||||
// new-shadow on Window.Toggle so the open path also writes Config —
|
// new-shadow on Window.Toggle so the open path also writes Config —
|
||||||
// OnClose already covers the close path through the base behaviour.
|
// OnClose already covers the close path through the base behaviour.
|
||||||
public new void Toggle()
|
public new void Toggle()
|
||||||
|
|||||||
Reference in New Issue
Block a user