From 2d6333f84c3d205856179aa85ba6f4d347fcd12c Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 24 Aug 2026 19:54:28 +0200 Subject: [PATCH] fix(ui): disabled scrollbars keep retail hover hot-tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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=. 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 --- src/AcDream.App/UI/UiScrollbar.cs | 21 ++++- .../UI/Layout/CharacterStatControllerTests.cs | 45 +++++++++++ .../AcDream.App.Tests/UI/UiScrollbarTests.cs | 77 +++++++++++++++++++ 3 files changed, 141 insertions(+), 2 deletions(-) diff --git a/src/AcDream.App/UI/UiScrollbar.cs b/src/AcDream.App/UI/UiScrollbar.cs index 3ecef835..d6569b3c 100644 --- a/src/AcDream.App/UI/UiScrollbar.cs +++ b/src/AcDream.App/UI/UiScrollbar.cs @@ -208,8 +208,21 @@ public sealed class UiScrollbar : UiElement internal bool IsPresentationVisible => !HideWhenDisabled || !IsModelDisabled; + /// + /// 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=<none>). + /// Only a presentation-hidden bar (0x79 + disabled) ignores the pointer. + /// protected override bool OnHitTest(float localX, float localY) - => !IsModelDisabled && base.OnHitTest(localX, localY); + => IsPresentationVisible && base.OnHitTest(localX, localY); /// /// 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; diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs index 96e142b2..19ba6170 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs @@ -1953,6 +1953,51 @@ public class CharacterStatControllerTests Assert.Equal(RetailScrollbarChrome.ThumbMidRollover, scrollbar.ThumbRolloverSprite); } + /// + /// 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). + /// + [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().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) diff --git a/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs b/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs index daa501a3..f9e51e3f 100644 --- a/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs +++ b/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs @@ -573,6 +573,83 @@ public class UiScrollbarTests Assert.Equal(1u, bar.ActiveThumbSpriteForTest); } + /// + /// 2026-08-24 live-probe root cause: a content-fits (disabled) bar was + /// hit-TRANSPARENT, so hovering it reported widget=<none> 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. + /// + [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))); + } + + /// + /// Root-level repro for the 2026-08-24 owner report "hovering the + /// scrollbar and arrows does nothing": drives real + /// hit-test/hover dispatch instead of + /// synthetic direct OnEvent calls. + /// + [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;