fix(ui): disabled scrollbars keep retail hover hot-tracking

Owner report + live [ui-hover] probe (2026-08-24): hovering a
content-fits scrollbar did nothing because IsModelDisabled made the
whole bar hit-TRANSPARENT — every hover over it reported
widget=<none>. Retail's arrows and thumb are real child elements whose
Normal_rollover hot-tracking keeps running while the scrollbar is
disabled (UpdateLayout @0x004710d0 only hides the page-click regions,
children 4-7, and — with attribute 0x79 — the whole bar); scrolling
stays inert through geometry, not an input gate: a full-track thumb has
zero travel and the line/page steps clamp against nothing.

OnHitTest and the input path now gate on presentation visibility only.
A visible disabled bar hover-highlights and consumes clicks without
scrolling; a HideWhenDisabled bar stays inert. New root-level hover
tests drive real UiRoot hit-test dispatch (bare widget + the mounted
production character fixture) so this class of "state machine green,
pointer never arrives" bug fails loudly.

User-verified live 2026-08-24 ("bar works now").

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-24 19:54:28 +02:00
parent 8fd3d1a9f0
commit 2d6333f84c
3 changed files with 141 additions and 2 deletions

View file

@ -208,8 +208,21 @@ public sealed class UiScrollbar : UiElement
internal bool IsPresentationVisible => !HideWhenDisabled || !IsModelDisabled;
/// <summary>
/// A content-fits (disabled) bar is still hit-testable and still
/// hover-highlights — retail's arrows and thumb are real child elements
/// whose Normal_rollover hot-tracking keeps running when the scrollbar
/// disables; the disabled state only hides the page-click regions
/// (UpdateLayout @0x004710d0's children 4-7) and, with attribute 0x79,
/// the whole bar. Scrolling input stays inert through geometry: with a
/// full-track thumb there is no travel and the line/page steps clamp to
/// nothing. The previous IsModelDisabled hit gate made the bar
/// hit-TRANSPARENT, which is why hovering it "did nothing" (2026-08-24
/// live probe: hovers over the bar reported widget=&lt;none&gt;).
/// Only a presentation-hidden bar (0x79 + disabled) ignores the pointer.
/// </summary>
protected override bool OnHitTest(float localX, float localY)
=> !IsModelDisabled && base.OnHitTest(localX, localY);
=> IsPresentationVisible && base.OnHitTest(localX, localY);
/// <summary>
/// Computes the thumb rectangle (local y origin and height) within the track area
@ -520,7 +533,11 @@ public sealed class UiScrollbar : UiElement
return false; // informational — never consumes
}
if (IsModelDisabled)
// Only a presentation-HIDDEN bar ignores input (see OnHitTest's own
// doc): a visible disabled bar keeps hover/pressed visuals exactly
// like retail's still-hot-tracking button/thumb children, while its
// scroll operations no-op through zero travel.
if (!IsPresentationVisible)
{
_draggingThumb = false;
_hoveredThumb = false;

View file

@ -1953,6 +1953,51 @@ public class CharacterStatControllerTests
Assert.Equal(RetailScrollbarChrome.ThumbMidRollover, scrollbar.ThumbRolloverSprite);
}
/// <summary>
/// 2026-08-24 owner report: "hovering the scrollbar and arrows does
/// nothing" — root-level hover through the REAL mounted character
/// fixture (production element tree, overlays and z-order included).
/// </summary>
[Fact]
public void ProductionFixture_HoveringTheSkillScrollbar_SelectsRolloverMedia()
{
var layout = FixtureLoader.LoadCharacter();
CharacterStatController.Bind(
layout,
SampleData.SampleCharacter,
spriteResolve: id => (id, 16, 16));
var root = new UiRoot { Width = 800f, Height = 600f };
root.AddChild(layout.Root);
ApplyLayoutPass(layout.Root);
ClickTab(layout, left: 92f);
var page = layout.Root.Children.Single(
e => e.DatElementId == CharacterStatController.AttributesPageId);
var list = Descendants(page).Single(
e => e.DatElementId == CharacterStatController.ListBoxId);
var scrollbar = list.Parent!.Children.OfType<UiScrollbar>().Single(
e => e.DatElementId == CharacterStatController.ListScrollbarId);
Assert.True(scrollbar.Visible);
// The headless harness never measures row content; give the REAL
// bound model an overflowing extent so the bar is enabled the way
// a populated skills list is in production.
Assert.NotNull(scrollbar.Model);
scrollbar.Model!.ContentHeight = 800;
scrollbar.Model.ViewHeight = 398;
Assert.False(scrollbar.IsModelDisabled);
var screen = scrollbar.ScreenPosition;
// Over the up arrow (5px into the 16px top button).
root.OnMouseMove((int)(screen.X + 8f), (int)(screen.Y + 5f));
Assert.Equal(
RetailScrollbarChrome.UpRollover, scrollbar.ActiveStartSpriteForTest);
// Over the thumb (just below the up button; thumb starts at track top).
root.OnMouseMove((int)(screen.X + 8f), (int)(screen.Y + 24f));
Assert.Equal(
RetailScrollbarChrome.ThumbMidRollover, scrollbar.ActiveThumbSpriteForTest);
}
// ── Helpers ──────────────────────────────────────────────────────────────
private static void ClickTab(ImportedLayout layout, float left)

View file

@ -573,6 +573,83 @@ public class UiScrollbarTests
Assert.Equal(1u, bar.ActiveThumbSpriteForTest);
}
/// <summary>
/// 2026-08-24 live-probe root cause: a content-fits (disabled) bar was
/// hit-TRANSPARENT, so hovering it reported widget=&lt;none&gt; and no
/// state ever highlighted. Retail's arrows/thumb are real child
/// elements that keep hot-tracking while the scrollbar is disabled —
/// only the page regions (and, with 0x79, the whole bar) go away.
/// </summary>
[Fact]
public void DisabledVisibleBar_StillHoverHighlights_ButNeverScrolls()
{
var root = new UiRoot { Width = 800f, Height = 600f };
var model = new UiScrollable { ContentHeight = 100, ViewHeight = 150, LineHeight = 10 };
var bar = new UiScrollbar
{
Left = 100, Top = 100, Width = 16f, Height = 200f,
Model = model,
UpSprite = 1u, UpRolloverSprite = 2u,
ThumbSprite = 4u, ThumbRolloverSprite = 5u,
};
root.AddChild(bar);
Assert.True(bar.IsModelDisabled);
Assert.True(bar.IsPresentationVisible);
// Hover the up arrow: highlight, exactly like retail's full-bar state.
root.OnMouseMove(108, 105);
Assert.Equal(2u, bar.ActiveStartSpriteForTest);
// Hover the (full-track) thumb: highlight.
root.OnMouseMove(108, 130);
Assert.Equal(5u, bar.ActiveThumbSpriteForTest);
// Clicking consumes (the bar is opaque UI) but cannot scroll.
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseDown, Data1: 8, Data2: 5)));
Assert.Equal(0, model.ScrollY);
// A presentation-HIDDEN bar (0x79 + disabled) stays inert.
bar.HideWhenDisabled = true;
Assert.False(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseDown, Data1: 8, Data2: 5)));
}
/// <summary>
/// Root-level repro for the 2026-08-24 owner report "hovering the
/// scrollbar and arrows does nothing": drives real
/// <see cref="UiRoot.OnMouseMove"/> hit-test/hover dispatch instead of
/// synthetic direct OnEvent calls.
/// </summary>
[Fact]
public void RootMouseMove_OverArrowAndThumb_SelectsRolloverMedia()
{
var root = new UiRoot { Width = 800f, Height = 600f };
var model = new UiScrollable { ContentHeight = 400, ViewHeight = 150, LineHeight = 10 };
var bar = new UiScrollbar
{
Left = 100, Top = 100, Width = 16f, Height = 200f,
Model = model,
UpSprite = 1u, UpRolloverSprite = 2u, UpPressedSprite = 3u,
DownSprite = 7u, DownRolloverSprite = 8u,
ThumbSprite = 4u, ThumbRolloverSprite = 5u,
};
root.AddChild(bar);
// Over the up arrow (local y = 5, inside the 16px button).
root.OnMouseMove(108, 105);
Assert.Equal(2u, bar.ActiveStartSpriteForTest);
// Over the thumb (track 16..184, ratio 150/400=0.375 → thumb 16..79
// local, 116..179 screen).
root.OnMouseMove(108, 130);
Assert.Equal(1u, bar.ActiveStartSpriteForTest);
Assert.Equal(5u, bar.ActiveThumbSpriteForTest);
// Over the down arrow (local y >= 184).
root.OnMouseMove(108, 290);
Assert.Equal(4u, bar.ActiveThumbSpriteForTest);
Assert.Equal(8u, bar.ActiveEndSpriteForTest);
}
private sealed class NullGpuFrameSource : ICurrentGpuFrameSource
{
public IGpuFrame? CurrentFrame => null;