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

@ -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)