diff --git a/AGENTS.md b/AGENTS.md index 9525771c..978805b3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -116,7 +116,7 @@ user-gated. Deterministic world-lifecycle automation protects fresh login, outdoor/world-edge/dungeon travel, same-location revisit, exact graceful disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts. Release builds; the structural closeout plus interaction-completion Slice 1 -pass 293 focused tests, 3,466 App tests / 3 skips, and 7,839 complete-solution +pass 293 focused tests, 3,472 App tests / 3 skips, and 7,845 complete-solution tests / 5 skips. **Current work order:** the six-slice pre-M4 world-interaction completion diff --git a/CLAUDE.md b/CLAUDE.md index 5fe42459..0677e9cc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -114,7 +114,7 @@ user-gated. Deterministic world-lifecycle automation protects fresh login, outdoor/world-edge/dungeon travel, same-location revisit, exact graceful disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts. Release builds; the structural closeout plus interaction-completion Slice 1 -pass 293 focused tests, 3,466 App tests / 3 skips, and 7,839 complete-solution +pass 293 focused tests, 3,472 App tests / 3 skips, and 7,845 complete-solution tests / 5 skips. **Current work order:** the six-slice pre-M4 world-interaction completion diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index e041cc06..b0fd0a7c 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -14,8 +14,12 @@ bodies begin: spell-bar overflow controls, status-bar Use/Assess commands, assessment information, equipped-child picking, vendor browsing, and server-authoritative vendor transactions. Slice 1 is active and ports the DAT-authored arrow-only favorite-spell scrollbar through the shared retained -scrollbar/list model. Its implementation and complete Release suite pass; -connected overflow-arrow visual acceptance is pending. +scrollbar/list model. The corrected import preserves authored arrow sides and +rollover/pressed media, passive refresh preserves manual offsets, and the +mixed-parent DAT anchor chain produces the fixed retail capacity of 18 visible +favorite cells (nine numbered plus nine unnumbered). Its +implementation and complete Release suite pass; connected overflow-arrow +visual acceptance is pending. --- diff --git a/docs/plans/2026-07-23-world-interaction-completion.md b/docs/plans/2026-07-23-world-interaction-completion.md index 6178bcd1..2c771bf2 100644 --- a/docs/plans/2026-07-23-world-interaction-completion.md +++ b/docs/plans/2026-07-23-world-interaction-completion.md @@ -43,12 +43,18 @@ Each slice begins with named-retail research, produces pseudocode and conformance tests, updates the divergence register if required, and lands as a separate bisectable commit. A visual gate follows each UI-bearing slice. -Slice 1 now imports both 23-pixel arrow buttons and HideDisabled from the real -combat fixture, shares the list's single pixel-scroll model, advances one -32-pixel cell per press, and exposes programmatically selected spells. The -focused 57-test set, all 3,466 App tests / 3 skips, the Release solution build, -and all 7,839 solution tests / 5 skips pass. Only the connected visual gate -remains before Slice 2 starts. +Slice 1 now imports both 23-pixel arrow buttons, their rollover/pressed media, +and HideDisabled from the real combat fixture; places their artwork by the +authored leading/trailing positions; shares the list's single pixel-scroll +model; advances one 32-pixel cell per press; and exposes only actively selected +spells. Passive object/endowment refresh preserves a manual offset. The mixed +610/800-pixel DAT anchor chain is solved to a fixed 747-pixel combat root, +producing exactly 18 visible 32-pixel favorite cells (nine numbered plus nine +unnumbered) without consuming the overflow. The initial 57-test/full-suite gate passed; +the corrective 104-test focus set, 3,472 App tests / 3 skips, Release solution +build, and 7,845 complete-solution tests / 5 skips pass. The connected gate is +pending after user feedback on arrow sides, rollover, width, and offset +persistence. ## Slice 1 — spell-bar overflow arrows @@ -74,7 +80,8 @@ Named retail references and executable pseudocode are recorded in 1. Generalize `DatWidgetFactory.BuildScrollbar` so horizontal and vertical scrollbars both import the referenced decrement/increment children, - including their authored dimensions and HideDisabled property. + including their authored positions, dimensions, Normal/rollover/pressed + media, and HideDisabled property. 2. Generalize `UiScrollbar` to use distinct authored decrement/increment extents for rendering, hit-testing, track geometry, and dragging. Preserve the existing 16-pixel default for layouts whose button children are absent. @@ -85,10 +92,14 @@ Named retail references and executable pseudocode are recorded in `UiItemList.Scroll`, enable horizontal scrolling, and retain one independent pixel offset per favorite tab. 5. Match `SpellCastSubMenu::SetSelected`: selecting a spell through keyboard, - shortcut, or code scrolls that item into view. -6. Pin the importer, arrow hit extents, 32-pixel step, no-overflow behavior, + shortcut, or code scrolls that item into view; passive state refresh does + not re-expose it. +6. Carry the combat root's mixed-parent raw-edge policies through the complete + imported tree and solve for the retail 18-cell favorite viewport; keep that + HUD capacity fixed across desktop resizes. +7. Pin the importer, arrow hit extents, 32-pixel step, no-overflow behavior, controller binding, and selection exposure with focused App tests. -7. Run the App Release suite, solution Release build, and complete Release +8. Run the App Release suite, solution Release build, and complete Release suite. Then update the roadmap/memory and request the connected visual gate: place more favorites than fit, scroll both directions, change tabs, and verify arrows disappear on a non-overflowing tab. diff --git a/docs/research/2026-07-23-retail-spellbar-overflow-pseudocode.md b/docs/research/2026-07-23-retail-spellbar-overflow-pseudocode.md index a46b6a20..93527100 100644 --- a/docs/research/2026-07-23-retail-spellbar-overflow-pseudocode.md +++ b/docs/research/2026-07-23-retail-spellbar-overflow-pseudocode.md @@ -13,12 +13,12 @@ Each of the eight `SpellCastSubMenu` groups contains: ```text favorite group scrollbar 0x100000B5, 685x36 - decrement button 0x10000071, 23x36 + increment reference 0x10000072, authored x=0, 23x36 + Normal 0x06004CDE (left-facing arrow) + Rollover 0x06004CDF + decrement reference 0x10000071, authored x=63, 23x36 Normal 0x06004CDC Rollover 0x06004CDD - increment button 0x10000072, 23x36 - Normal 0x06004CDE - Rollover 0x06004CDF item list 0x100000B6, x=23, y=2, 639x32 scrollbar property 0x77 = increment button 0x10000072 @@ -28,7 +28,12 @@ item-list property 0x71 = horizontal scrollbar 0x100000B5 ``` The spell-bar scrollbar has no authored track or thumb. Its 23-pixel buttons -occupy the space immediately outside the 639-pixel item-list viewport. +occupy the space immediately outside the 639-pixel item-list viewport. The +property names do not determine their drawn side: +`UIElement_Scrollbar::UpdateScrollingArea @ 0x00470AA0` places the 0x77 +increment reference at the leading/top-left edge and the 0x78 decrement +reference at the trailing/bottom-right edge. For this horizontal fixture that +means `0x06004CDE` on the left and `0x06004CDC` on the right. ## Initialization and ownership @@ -85,8 +90,8 @@ UpdateScrollbarSize(horizontal): Scrollbar.UpdateLayout(): scrollingArea = own rectangle - position decrement button at the start and subtract its authored width - position increment button at the end and subtract its authored width + position property-0x77 button at the start and subtract its authored width + position property-0x78 button at the end and subtract its authored width update thumb/track geometry, when those children exist Visible = NOT (Disabled AND HideDisabled) ``` @@ -95,6 +100,31 @@ For the favorite bar, disabled plus HideDisabled removes both arrows when every favorite/empty cell fits. With overflow, the arrows remain visible and the item list clips the horizontally translated cells. +The combat LayoutDesc deliberately mixes design extents: its outer combat root +is 610 pixels wide, while the magic page's children are authored against an +800-pixel parent. Applying retail +`UIElement::UpdateForParentSizeChange @ 0x00462640` through that complete +raw-edge chain gives a 439-pixel favorite viewport at the 610-pixel root—the +13-slot short bar seen before this slice. + +The retail presentation exposes 18 favorite cells: nine shortcut-numbered +cells plus nine unnumbered cells. At 32 pixels per cell the target viewport is +576 pixels. Because every horizontal raw-edge stage carries the parent delta +one-for-one, solving the imported tree for that viewport yields: + +```text +outer combat root = 747 px +magic page = 737 px (5 px inset on each side) +favorite group = 622 px (x=40, followed by Cast) +scrollbar = 622 px (23 + 576 + 23) +favorite list = 576 px (18 * 32) +Cast = x=662, width=75 inside the magic page +``` + +This is a fixed visible-capacity HUD, not a desktop-wide strip. Stretching it +to the display width makes the favorite content fit, which legitimately +disables and hides the `HideDisabled` overflow arrows. + ## Scroll delta and selection exposure Retail: @@ -127,15 +157,23 @@ SpellCastSubMenu.SetSelected(spellId): ``` The favorite cells are 32 pixels wide, so a line arrow moves exactly 32 -pixels. Programmatic or keyboard selection exposes the selected cell even when -it was outside the prior viewport. +pixels. An actual programmatic, keyboard, or pointer selection exposes the +selected cell even when it was outside the prior viewport. Passive endowment +or object-table refresh does not re-run `SetSelected`; doing so would undo a +manual scrollbar offset. ## Port constraints - Preserve the imported `0x71`/`0x77`/`0x78` relationships. - Preserve each authored button's dimension; do not assume the 16-pixel chat scrollbar button size. +- Preserve each authored button's Normal, Normal_rollover, and Normal_pressed + media through the shared scrollbar presentation path. - Do not fabricate a track/thumb for this arrow-only control. - Derive disabled state from the shared model's content and viewport extents. - Hide disabled controls from both drawing and hit-testing. - Keep every favorite tab's scroll offset independent. +- Carry the DAT raw-edge policies through the full imported combat tree and + solve the root width for an 18-cell (576-pixel) favorite viewport. +- Keep the resulting 747-pixel HUD width fixed across desktop resizes; do not + turn the favorite bar into a desktop-wide strip. diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs index 2e38ef31..ea25621e 100644 --- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs +++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs @@ -126,8 +126,11 @@ public static class DatWidgetFactory /// /// Bind inherited scrollbar media structurally. Property 0x77 names the - /// increment button and 0x78 the decrement button; the remaining Type-1 - /// child is the thumb with ordered top/middle/bottom image slices. + /// increment button and 0x78 the decrement button; retail + /// UIElement_Scrollbar::UpdateScrollingArea @ 0x00470AA0 then places + /// those referenced child buttons by their authored leading/trailing + /// positions. The remaining Type-1 child is the thumb with ordered + /// top/middle/bottom image slices. /// private static UiScrollbar BuildScrollbar( ElementInfo info, @@ -144,17 +147,31 @@ public static class DatWidgetFactory uint decrementId = ReferencedElementId(info, 0x78u); ElementInfo? increment = info.Children.FirstOrDefault(child => child.Id == incrementId); ElementInfo? decrement = info.Children.FirstOrDefault(child => child.Id == decrementId); - bar.UpSprite = decrement is null ? 0u : DefaultImage(decrement); - bar.DownSprite = increment is null ? 0u : DefaultImage(increment); + ElementInfo? leadingButton = new[] { increment, decrement } + .Where(child => child is not null) + .OrderBy(child => bar.Horizontal ? child!.X : child!.Y) + .ThenBy(child => child!.ReadOrder) + .FirstOrDefault(); + ElementInfo? trailingButton = new[] { increment, decrement } + .Where(child => child is not null) + .OrderByDescending(child => bar.Horizontal ? child!.X : child!.Y) + .ThenByDescending(child => child!.ReadOrder) + .FirstOrDefault(); + bar.UpSprite = ButtonStateImage(leadingButton, "Normal"); + bar.UpRolloverSprite = ButtonStateImage(leadingButton, "Normal_rollover"); + bar.UpPressedSprite = ButtonStateImage(leadingButton, "Normal_pressed"); + bar.DownSprite = ButtonStateImage(trailingButton, "Normal"); + bar.DownRolloverSprite = ButtonStateImage(trailingButton, "Normal_rollover"); + bar.DownPressedSprite = ButtonStateImage(trailingButton, "Normal_pressed"); if (info.TryGetEffectiveBool(0x79u, out bool hideDisabled)) bar.HideWhenDisabled = hideDisabled; if (bar.Horizontal) { - if (decrement is { Width: > 0f }) - bar.DecrementButtonExtent = decrement.Width; - if (increment is { Width: > 0f }) - bar.IncrementButtonExtent = increment.Width; + if (leadingButton is { Width: > 0f }) + bar.DecrementButtonExtent = leadingButton.Width; + if (trailingButton is { Width: > 0f }) + bar.IncrementButtonExtent = trailingButton.Width; // Retail horizontal scrollbars use structural child ids: element 1 is // the thumb and element 4 is the optional child-authored track. @@ -207,10 +224,10 @@ public static class DatWidgetFactory return bar; } - if (decrement is { Height: > 0f }) - bar.DecrementButtonExtent = decrement.Height; - if (increment is { Height: > 0f }) - bar.IncrementButtonExtent = increment.Height; + if (leadingButton is { Height: > 0f }) + bar.DecrementButtonExtent = leadingButton.Height; + if (trailingButton is { Height: > 0f }) + bar.IncrementButtonExtent = trailingButton.Height; ElementInfo? thumb = info.Children.FirstOrDefault(child => child.Type == 1u && child.Id != incrementId && child.Id != decrementId); @@ -272,6 +289,19 @@ public static class DatWidgetFactory return 0u; } + private static uint ButtonStateImage(ElementInfo? info, string stateName) + { + if (info is null) + return 0u; + if (info.StateMedia.TryGetValue(stateName, out var media)) + return media.File; + UiStateInfo? state = info.States.Values.FirstOrDefault( + candidate => string.Equals(candidate.Name, stateName, StringComparison.Ordinal)); + if (state?.Image is { } image) + return image.File; + return stateName == "Normal" ? DefaultImage(info) : 0u; + } + // ── Meter ──────────────────────────────────────────────────────────────── /// diff --git a/src/AcDream.App/UI/Layout/RetailCombatLayout.cs b/src/AcDream.App/UI/Layout/RetailCombatLayout.cs new file mode 100644 index 00000000..55ee92df --- /dev/null +++ b/src/AcDream.App/UI/Layout/RetailCombatLayout.cs @@ -0,0 +1,49 @@ +namespace AcDream.App.UI.Layout; + +/// Retail geometry policy for the shared melee/missile/magic combat root. +internal static class RetailCombatLayout +{ + internal const int VisibleFavoriteSlots = 18; + internal const float FavoriteCellWidth = 32f; + + /// + /// Resize the imported combat tree so its favorite list exposes exactly the + /// requested number of cells. The DAT deliberately authors the outer combat + /// root at 610 pixels while the magic page's descendants use an 800-pixel + /// design parent. Retail UIElement::UpdateForParentSizeChange @ + /// 0x00462640 carries that width delta through every raw-edge policy. + /// Applying the same chain and solving for the list viewport keeps the + /// endowment, arrows, list, Cast button, and outer frame in one coherent + /// retained layout instead of resizing individual controls ad hoc. + /// + internal static float FitFavoriteSlots( + ImportedLayout layout, + int visibleSlots = VisibleFavoriteSlots, + float cellWidth = FavoriteCellWidth) + { + ArgumentNullException.ThrowIfNull(layout); + if (visibleSlots < 1) + throw new ArgumentOutOfRangeException(nameof(visibleSlots)); + if (!(cellWidth > 0f) || !float.IsFinite(cellWidth)) + throw new ArgumentOutOfRangeException(nameof(cellWidth)); + + UiElement root = layout.Root; + ApplyDescendantLayout(root); + if (layout.FindElement(SpellcastingUiController.FavoriteListId) is not UiItemList list) + throw new InvalidOperationException("Retail combat layout has no favorite spell list."); + + float targetViewport = visibleSlots * cellWidth; + root.Width = MathF.Max(0f, root.Width + targetViewport - list.Width); + ApplyDescendantLayout(root); + return root.Width; + } + + private static void ApplyDescendantLayout(UiElement parent) + { + foreach (UiElement child in parent.Children) + { + child.ApplyAnchor(parent.Width, parent.Height); + ApplyDescendantLayout(child); + } + } +} diff --git a/src/AcDream.App/UI/Layout/RetailWindowFrame.cs b/src/AcDream.App/UI/Layout/RetailWindowFrame.cs index 15b40179..6c505e9f 100644 --- a/src/AcDream.App/UI/Layout/RetailWindowFrame.cs +++ b/src/AcDream.App/UI/Layout/RetailWindowFrame.cs @@ -64,6 +64,12 @@ public static class RetailWindowFrame public bool DrawChromeCenter { get; init; } = true; public bool Visible { get; init; } = true; + /// + /// Top-level desktop anchors. Most movable windows are unanchored; fixed + /// retail HUD roots can preserve their authored screen-edge margins. + /// + public AnchorEdges OuterAnchors { get; init; } = AnchorEdges.None; + public AnchorEdges ContentAnchors { get; init; } = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom; public bool? ContentClickThrough { get; init; } @@ -133,7 +139,7 @@ public static class RetailWindowFrame outerFrame.Top = options.Top; outerFrame.Width = outerWidth; outerFrame.Height = outerHeight; - outerFrame.Anchors = AnchorEdges.None; + outerFrame.Anchors = options.OuterAnchors; outerFrame.Draggable = options.Draggable; outerFrame.Resizable = options.Resizable; outerFrame.ResizeX = options.ResizeX; diff --git a/src/AcDream.App/UI/Layout/SpellcastingUiController.cs b/src/AcDream.App/UI/Layout/SpellcastingUiController.cs index e06fef3b..c7b1206c 100644 --- a/src/AcDream.App/UI/Layout/SpellcastingUiController.cs +++ b/src/AcDream.App/UI/Layout/SpellcastingUiController.cs @@ -251,8 +251,6 @@ public sealed class SpellcastingUiController : IRetainedPanelController _endowmentSelected[_activeTab] = favorites.Count == 0 && _endowmentItemId != 0u; } SyncSelection(); - if (_selected[_activeTab] is uint visibleSpell) - ScrollSelectedSpellIntoView(_activeTab, visibleSpell); UpdateCastAvailability(); } diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 17b46f33..f57249bb 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -764,11 +764,12 @@ public sealed class RetailUiRuntime : IDisposable strings.Resolve); labels = info is null ? null : CombatUiLabels.Resolve(info, strings); } - if (layout is null || labels is null) + if (info is null || layout is null || labels is null) { Console.WriteLine("[M2] combat: LayoutDesc 0x21000073 not found."); return; } + float combatWidth = RetailCombatLayout.FitFavoriteSlots(layout); CombatUiController? controller = Layout.CombatUiController.Bind( layout, @@ -821,9 +822,13 @@ public sealed class RetailUiRuntime : IDisposable { WindowName = WindowNames.Combat, Chrome = RetailWindowChrome.Imported, - Left = Math.Max(0f, (Host.Root.Width - root.Width) * 0.5f), + Left = 0f, Top = Math.Max(0f, Host.Root.Height - root.Height - 10f), + ContentWidth = combatWidth, + DatConstraintSource = info, + OuterAnchors = AnchorEdges.Left | AnchorEdges.Bottom, Visible = false, + Draggable = false, Resizable = false, ResizeX = false, ResizeY = false, diff --git a/src/AcDream.App/UI/UiElement.cs b/src/AcDream.App/UI/UiElement.cs index b59b083d..ea484b1e 100644 --- a/src/AcDream.App/UI/UiElement.cs +++ b/src/AcDream.App/UI/UiElement.cs @@ -233,6 +233,14 @@ public abstract class UiElement /// self-driven interior drag such as text selection). Default false. public virtual bool HandlesClick => false; + /// + /// Whether pointer movement should continue reaching this element while it remains + /// hovered and no mouse capture is active. Composite controls use this to update + /// procedural sub-region hover state without broadening mouse-move delivery to every + /// retained widget. + /// + public virtual bool ReceivesHoverMouseMove => false; + /// Minimum size enforced while resizing. public float MinWidth { get; set; } = 40f; public float MinHeight { get; set; } = 40f; diff --git a/src/AcDream.App/UI/UiRoot.cs b/src/AcDream.App/UI/UiRoot.cs index 03b0b726..7b2aa7d4 100644 --- a/src/AcDream.App/UI/UiRoot.cs +++ b/src/AcDream.App/UI/UiRoot.cs @@ -851,7 +851,12 @@ public sealed class UiRoot : UiElement private void UpdateHover(int x, int y) { var (w, _, _) = HitTestTopDown(x, y); - if (ReferenceEquals(w, _hoverWidget)) return; + if (ReferenceEquals(w, _hoverWidget)) + { + if (w?.ReceivesHoverMouseMove == true) + DispatchMouseMove(w, x, y); + return; + } if (_hoverWidget is not null) { @@ -863,7 +868,13 @@ public sealed class UiRoot : UiElement _tooltipFired = false; if (w is not null) { - var enter = new UiEvent(w.EventId, w, UiEventType.HoverEnter); + var screen = w.ScreenPosition; + var enter = new UiEvent( + w.EventId, + w, + UiEventType.HoverEnter, + Data1: (int)(x - screen.X), + Data2: (int)(y - screen.Y)); w.OnEvent(in enter); } } diff --git a/src/AcDream.App/UI/UiScrollbar.cs b/src/AcDream.App/UI/UiScrollbar.cs index 49a2f0d1..25c04acc 100644 --- a/src/AcDream.App/UI/UiScrollbar.cs +++ b/src/AcDream.App/UI/UiScrollbar.cs @@ -21,6 +21,8 @@ namespace AcDream.App.UI; /// public sealed class UiScrollbar : UiElement { + public override bool ReceivesHoverMouseMove => true; + /// The scroll model this bar reflects + drives (shared with the transcript). public UiScrollable? Model { get; set; } @@ -83,6 +85,14 @@ public sealed class UiScrollbar : UiElement /// Down-arrow button sprite id (0x06004C6C Normal state, element 0x10000072). public uint DownSprite { get; set; } + /// Rollover and pressed media for the start/decrement button. + public uint UpRolloverSprite { get; set; } + public uint UpPressedSprite { get; set; } + + /// Rollover and pressed media for the end/increment button. + public uint DownRolloverSprite { get; set; } + public uint DownPressedSprite { get; set; } + /// /// Authored extent of the decrement button along the scrollbar axis. Retail /// positions and subtracts the referenced button's real size; chat uses @@ -108,6 +118,15 @@ public sealed class UiScrollbar : UiElement private bool _draggingThumb; private float _dragOffsetY; private float _dragOffsetX; + private EndButton _hoveredButton; + private EndButton _pressedButton; + + private enum EndButton + { + None, + Decrement, + Increment, + } public UiScrollbar() { CapturesPointerDrag = true; } @@ -201,8 +220,8 @@ public sealed class UiScrollbar : UiElement float incrementExtent = AxisExtent(IncrementButtonExtent, Height); // Decrement/up and increment/down use their authored button heights. - DrawSprite(ctx, resolve, UpSprite, 0f, 0f, Width, decrementExtent); - DrawSprite(ctx, resolve, DownSprite, + DrawSprite(ctx, resolve, ActiveStartSprite, 0f, 0f, Width, decrementExtent); + DrawSprite(ctx, resolve, ActiveEndSprite, 0f, Height - incrementExtent, Width, incrementExtent); // Thumb — only when content overflows the view. Retail 3-slice: top cap + @@ -235,8 +254,8 @@ public sealed class UiScrollbar : UiElement float decrementExtent = AxisExtent(DecrementButtonExtent, Width); float incrementExtent = AxisExtent(IncrementButtonExtent, Width); DrawTiled(ctx, resolve, TrackSprite, 0f, 0f, Width, Height); - DrawSprite(ctx, resolve, UpSprite, 0f, 0f, decrementExtent, Height); - DrawSprite(ctx, resolve, DownSprite, + DrawSprite(ctx, resolve, ActiveStartSprite, 0f, 0f, decrementExtent, Height); + DrawSprite(ctx, resolve, ActiveEndSprite, Width - incrementExtent, 0f, incrementExtent, Height); if (!model.HasOverflow) return; @@ -322,9 +341,24 @@ public sealed class UiScrollbar : UiElement if (IsModelDisabled) { _draggingThumb = false; + _hoveredButton = EndButton.None; + _pressedButton = EndButton.None; return false; } + if (e.Type == UiEventType.HoverEnter) + { + _hoveredButton = ButtonAt(e.Data1, e.Data2); + return true; + } + if (e.Type == UiEventType.HoverLeave) + { + _hoveredButton = EndButton.None; + return true; + } + if (e.Type == UiEventType.MouseMove) + _hoveredButton = ButtonAt(e.Data1, e.Data2); + if (Horizontal && ScalarChanged is not null) return OnScalarEvent(e); @@ -339,6 +373,7 @@ public sealed class UiScrollbar : UiElement { // e.Data1 = local X, e.Data2 = local Y (int pixel coords, see UiRoot hit dispatch). float ly = e.Data2; + _pressedButton = ButtonAt(e.Data1, e.Data2); float decrementExtent = AxisExtent(DecrementButtonExtent, Height); float incrementExtent = AxisExtent(IncrementButtonExtent, Height); @@ -386,6 +421,7 @@ public sealed class UiScrollbar : UiElement case UiEventType.MouseUp: _draggingThumb = false; + _pressedButton = EndButton.None; return true; } @@ -400,6 +436,7 @@ public sealed class UiScrollbar : UiElement case UiEventType.MouseDown: { float x = e.Data1; + _pressedButton = ButtonAt(e.Data1, e.Data2); float decrementExtent = AxisExtent(DecrementButtonExtent, Width); float incrementExtent = AxisExtent(IncrementButtonExtent, Width); if (x < decrementExtent) { m.ScrollByLines(-1); return true; } @@ -437,6 +474,7 @@ public sealed class UiScrollbar : UiElement case UiEventType.MouseUp: _draggingThumb = false; + _pressedButton = EndButton.None; return true; } return false; @@ -475,6 +513,7 @@ public sealed class UiScrollbar : UiElement case UiEventType.MouseUp: _draggingThumb = false; + _pressedButton = EndButton.None; return true; } @@ -497,6 +536,48 @@ public sealed class UiScrollbar : UiElement ScalarChanged?.Invoke(ScalarPosition); } + private EndButton ButtonAt(float x, float y) + { + if (x < 0f || x >= Width || y < 0f || y >= Height) + return EndButton.None; + + if (Horizontal) + { + if (x < AxisExtent(DecrementButtonExtent, Width)) + return EndButton.Decrement; + if (x >= Width - AxisExtent(IncrementButtonExtent, Width)) + return EndButton.Increment; + return EndButton.None; + } + + if (y < AxisExtent(DecrementButtonExtent, Height)) + return EndButton.Decrement; + if (y >= Height - AxisExtent(IncrementButtonExtent, Height)) + return EndButton.Increment; + return EndButton.None; + } + + private uint ActiveStartSprite + => _pressedButton == EndButton.Decrement + && _hoveredButton == EndButton.Decrement + && UpPressedSprite != 0u + ? UpPressedSprite + : _hoveredButton == EndButton.Decrement && UpRolloverSprite != 0u + ? UpRolloverSprite + : UpSprite; + + private uint ActiveEndSprite + => _pressedButton == EndButton.Increment + && _hoveredButton == EndButton.Increment + && DownPressedSprite != 0u + ? DownPressedSprite + : _hoveredButton == EndButton.Increment && DownRolloverSprite != 0u + ? DownRolloverSprite + : DownSprite; + + internal uint ActiveStartSpriteForTest => ActiveStartSprite; + internal uint ActiveEndSpriteForTest => ActiveEndSprite; + private static float AxisExtent(float authoredExtent, float axisLength) => Math.Clamp(authoredExtent, 0f, MathF.Max(0f, axisLength)); } diff --git a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs index b568c400..f4c3b449 100644 --- a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs @@ -230,24 +230,32 @@ public class DatWidgetFactoryTests const uint IncrementId = 0x10000072u; const uint DecrementSprite = 0x06004CDCu; const uint IncrementSprite = 0x06004CDEu; + const uint DecrementRollover = 0x06004CDDu; + const uint IncrementRollover = 0x06004CDFu; var decrement = new ElementInfo { - Id = DecrementId, Type = 1u, Width = 23f, Height = 36f, + Id = DecrementId, Type = 1u, X = 63f, Width = 23f, Height = 36f, }; decrement.States[UiStateInfo.DirectStateId] = new UiStateInfo { Id = UiStateInfo.DirectStateId, Image = new UiImageMedia(DecrementSprite, 1), }; + decrement.StateMedia["Normal"] = (DecrementSprite, 1); + decrement.StateMedia["Normal_rollover"] = (DecrementRollover, 1); + decrement.StateMedia["Normal_pressed"] = (DecrementSprite, 1); var increment = new ElementInfo { - Id = IncrementId, Type = 1u, Width = 23f, Height = 36f, + Id = IncrementId, Type = 1u, X = 0f, Width = 23f, Height = 36f, }; increment.States[UiStateInfo.DirectStateId] = new UiStateInfo { Id = UiStateInfo.DirectStateId, Image = new UiImageMedia(IncrementSprite, 1), }; + increment.StateMedia["Normal"] = (IncrementSprite, 1); + increment.StateMedia["Normal_rollover"] = (IncrementRollover, 1); + increment.StateMedia["Normal_pressed"] = (IncrementSprite, 1); var info = new ElementInfo { Type = 11u, @@ -271,8 +279,12 @@ public class DatWidgetFactoryTests Assert.True(bar.HideWhenDisabled); Assert.Equal(23f, bar.DecrementButtonExtent); Assert.Equal(23f, bar.IncrementButtonExtent); - Assert.Equal(DecrementSprite, bar.UpSprite); - Assert.Equal(IncrementSprite, bar.DownSprite); + Assert.Equal(IncrementSprite, bar.UpSprite); + Assert.Equal(IncrementRollover, bar.UpRolloverSprite); + Assert.Equal(IncrementSprite, bar.UpPressedSprite); + Assert.Equal(DecrementSprite, bar.DownSprite); + Assert.Equal(DecrementRollover, bar.DownRolloverSprite); + Assert.Equal(DecrementSprite, bar.DownPressedSprite); Assert.Equal(0u, bar.TrackSprite); Assert.Equal(0u, bar.ThumbSprite); } diff --git a/tests/AcDream.App.Tests/UI/Layout/RetailWindowFrameTests.cs b/tests/AcDream.App.Tests/UI/Layout/RetailWindowFrameTests.cs index c48167aa..3d670338 100644 --- a/tests/AcDream.App.Tests/UI/Layout/RetailWindowFrameTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/RetailWindowFrameTests.cs @@ -7,6 +7,43 @@ public sealed class RetailWindowFrameTests { private static (uint, int, int) NoTex(uint _) => (0u, 0, 0); + [Theory] + [InlineData(9, 459f, 288f)] + [InlineData(13, 587f, 416f)] + [InlineData(18, 747f, 576f)] + public void CombatRoot_FitsRequestedFavoriteSlotCapacity( + int visibleSlots, + float expectedRootWidth, + float expectedListWidth) + { + ImportedLayout combat = LayoutImporter.Build( + FixtureLoader.LoadCombatInfos(), NoTex, datFont: null); + + float width = RetailCombatLayout.FitFavoriteSlots(combat, visibleSlots); + + Assert.Equal(expectedRootWidth, width); + Assert.Equal(expectedListWidth, combat.FindElement( + SpellcastingUiController.FavoriteListId)!.Width); + } + + [Fact] + public void CombatRoot_EighteenSlotsKeepArrowsAndCastInsideFrame() + { + ImportedLayout combat = LayoutImporter.Build( + FixtureLoader.LoadCombatInfos(), NoTex, datFont: null); + + RetailCombatLayout.FitFavoriteSlots(combat); + + UiElement group = combat.FindElement(0x100000AAu)!; + UiElement scrollbar = combat.FindElement(SpellcastingUiController.FavoriteScrollbarId)!; + UiElement cast = combat.FindElement(SpellcastingUiController.CastButtonId)!; + Assert.Equal(747f, combat.Root.Width); + Assert.Equal((40f, 622f), (group.Left, group.Width)); + Assert.Equal(622f, scrollbar.Width); + Assert.Equal((662f, 75f), (cast.Left, cast.Width)); + Assert.Equal(737f, cast.Left + cast.Width); + } + [Fact] public void ImportedChrome_MountsRootWithoutASecondFrame() { @@ -41,6 +78,30 @@ public sealed class RetailWindowFrameTests Assert.Equal(40f, content.MinWidth); } + [Fact] + public void ImportedChrome_CanPreserveFixedHudDesktopAnchors() + { + var root = new UiRoot { Width = 1920, Height = 1080 }; + var content = new UiPanel { Width = 1730, Height = 90 }; + + RetailWindowHandle handle = RetailWindowFrame.Mount( + root, + content, + NoTex, + new RetailWindowFrame.Options + { + WindowName = "combat", + Chrome = RetailWindowChrome.Imported, + Left = 0f, + Top = 980f, + OuterAnchors = AnchorEdges.Left | AnchorEdges.Bottom, + }); + + Assert.Equal( + AnchorEdges.Left | AnchorEdges.Bottom, + handle.OuterFrame.Anchors); + } + [Fact] public void NineSlice_UsesCroppedContentAndDatHeightConstraints() { diff --git a/tests/AcDream.App.Tests/UI/Layout/SpellcastingUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/SpellcastingUiControllerTests.cs index 0329169f..04c454c2 100644 --- a/tests/AcDream.App.Tests/UI/Layout/SpellcastingUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/SpellcastingUiControllerTests.cs @@ -16,7 +16,7 @@ public sealed class SpellcastingUiControllerTests { ImportedLayout layout = LayoutImporter.Build( FixtureLoader.LoadCombatInfos(), NoTex, datFont: null); - ApplyAnchors(layout.Root); + RetailCombatLayout.FitFavoriteSlots(layout); var spellbook = new Spellbook(); spellbook.OnSpellLearned(42u, 1f); spellbook.SetFavorite(0, 0, 42u); @@ -44,11 +44,13 @@ public sealed class SpellcastingUiControllerTests Assert.True(scrollbar.HideWhenDisabled); Assert.Equal(23f, scrollbar.DecrementButtonExtent); Assert.Equal(23f, scrollbar.IncrementButtonExtent); - Assert.Equal(0x06004CDCu, scrollbar.UpSprite); - Assert.Equal(0x06004CDEu, scrollbar.DownSprite); + Assert.Equal(0x06004CDEu, scrollbar.UpSprite); + Assert.Equal(0x06004CDFu, scrollbar.UpRolloverSprite); + Assert.Equal(0x06004CDCu, scrollbar.DownSprite); + Assert.Equal(0x06004CDDu, scrollbar.DownRolloverSprite); Assert.False(scrollbar.IsPresentationVisible); - Assert.Equal(439f, list.Width); - Assert.Equal(13, list.GetNumUIItems()); + Assert.Equal(576f, list.Width); + Assert.Equal(18, list.GetNumUIItems()); UiCatalogSlot favorite = Assert.IsType(list.GetItem(0)); Assert.Equal(42u, favorite.EntryId); @@ -63,7 +65,7 @@ public sealed class SpellcastingUiControllerTests Assert.Equal(i, slot.ShortcutNum); Assert.Same(empty, slot.ActiveDigitArray()); } - for (int i = 9; i < 13; i++) + for (int i = 9; i < 18; i++) { Assert.Equal(0x06001A97u, list.GetItem(i)!.EmptySprite); Assert.Equal(-1, list.GetItem(i)!.ShortcutNum); @@ -71,7 +73,7 @@ public sealed class SpellcastingUiControllerTests var cast = Assert.IsType( layout.FindElement(SpellcastingUiController.CastButtonId)); - Assert.Equal((525f, 75f), (cast.Left, cast.Width)); + Assert.Equal((662f, 75f), (cast.Left, cast.Width)); Assert.Equal(3, cast.FaceSegmentCount); Assert.Equal( [ @@ -87,7 +89,7 @@ public sealed class SpellcastingUiControllerTests { ImportedLayout layout = LayoutImporter.Build( FixtureLoader.LoadCombatInfos(), NoTex, datFont: null); - ApplyAnchors(layout.Root); + RetailCombatLayout.FitFavoriteSlots(layout); var spellbook = new Spellbook(); for (uint spellId = 1u; spellId <= 20u; spellId++) { @@ -105,7 +107,7 @@ public sealed class SpellcastingUiControllerTests UiScrollbar scrollbar = Descendants(group).OfType().Single( candidate => candidate.DatElementId == SpellcastingUiController.FavoriteScrollbarId); Assert.True(scrollbar.IsPresentationVisible); - Assert.Equal(201, list.Scroll.MaxScroll); + Assert.Equal(64, list.Scroll.MaxScroll); Assert.True(scrollbar.OnEvent(new UiEvent( 0u, scrollbar, UiEventType.MouseDown, Data1: (int)scrollbar.Width - 1))); @@ -117,6 +119,14 @@ public sealed class SpellcastingUiControllerTests controller.AddFavorite(20u); Assert.Equal(list.Scroll.MaxScroll, list.Scroll.ScrollY); + + Assert.True(scrollbar.OnEvent(new UiEvent( + 0u, scrollbar, UiEventType.MouseDown, Data1: 0))); + int manualOffset = list.Scroll.ScrollY; + objects.AddOrUpdate(new ClientObject { ObjectId = 99u, Name = "Unrelated" }); + controller.Tick(); + + Assert.Equal(manualOffset, list.Scroll.ScrollY); } [Fact] diff --git a/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs b/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs index 6d02b452..d2d26257 100644 --- a/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs +++ b/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs @@ -157,6 +157,47 @@ public class UiScrollbarTests Assert.Equal(64, model.ScrollY); } + [Fact] + public void HorizontalModel_UsesIndependentRolloverAndPressedArrowMedia() + { + var root = new UiRoot { Width = 300f, Height = 100f }; + var model = new UiScrollable + { + ContentHeight = 640, + ViewHeight = 320, + LineHeight = 32, + }; + var bar = new UiScrollbar + { + Width = 160f, + Height = 36f, + Horizontal = true, + Model = model, + UpSprite = 1u, + UpRolloverSprite = 2u, + UpPressedSprite = 3u, + DownSprite = 4u, + DownRolloverSprite = 5u, + DownPressedSprite = 6u, + }; + root.AddChild(bar); + + root.OnMouseMove(5, 10); + Assert.Equal(2u, bar.ActiveStartSpriteForTest); + Assert.Equal(4u, bar.ActiveEndSpriteForTest); + + // Moving between two regions of the same procedural scrollbar must + // refresh its sub-control hover, not wait for a whole-widget leave. + root.OnMouseMove(155, 10); + Assert.Equal(1u, bar.ActiveStartSpriteForTest); + Assert.Equal(5u, bar.ActiveEndSpriteForTest); + + root.OnMouseDown(UiMouseButton.Left, 155, 10); + Assert.Equal(6u, bar.ActiveEndSpriteForTest); + root.OnMouseUp(UiMouseButton.Left, 155, 10); + Assert.Equal(5u, bar.ActiveEndSpriteForTest); + } + [Fact] public void ModelWithoutOverflow_IsDisabledAndHideDisabledSuppressesPresentation() {