From e3605672bb0b6d89dc0e25cce69ff46309d6b515 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 15 Jul 2026 19:11:07 +0200 Subject: [PATCH] fix(ui): restore retail magic shortcut bar Port the retail horizontal ItemList empty-slot padding and share UIItem shortcut-number graphics with the status toolbar. Preserve all authored face children on compound DAT buttons so the three-piece Cast control reflows and renders as one complete button. Co-authored-by: OpenAI Codex --- docs/plans/2026-04-11-roadmap.md | 2 +- ...-retail-magic-ui-and-casting-pseudocode.md | 37 +++++ src/AcDream.App/UI/Layout/DatWidgetFactory.cs | 19 ++- .../UI/Layout/SpellcastingUiController.cs | 74 ++++++++-- src/AcDream.App/UI/RetailUiRuntime.cs | 29 +++- src/AcDream.App/UI/UiButton.cs | 137 +++++++++++++++--- src/AcDream.App/UI/UiCatalogSlot.cs | 8 + src/AcDream.App/UI/UiItemList.cs | 57 +++++++- src/AcDream.App/UI/UiItemSlot.cs | 52 +++++-- src/AcDream.App/UI/UiShortcutSlotGraphics.cs | 12 ++ .../Layout/SpellcastingUiControllerTests.cs | 69 ++++++++- tests/AcDream.App.Tests/UI/UiItemListTests.cs | 32 ++++ 12 files changed, 466 insertions(+), 62 deletions(-) create mode 100644 src/AcDream.App/UI/UiShortcutSlotGraphics.cs diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 6ae1974b..ad59ae63 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -531,7 +531,7 @@ behavior. Estimated 17–26 days focused work, 3–5 weeks calendar. - **Missile/portal VFX campaign Steps 0–5 implemented and independently reviewed 2026-07-14.** The retail oracle and packet fixtures are pinned, complete `PhysicsDesc` plus F754/F755 parsing and nine-channel gates are shipped, and App now has one canonical `LiveEntityRuntime` record per server-object incarnation. Logical register/unregister is separate from spatial rebucketing, so landblock churn and attached equipment retain identity without replaying renderer/script creation or reconstructing from stale spawn data. Canonical materialized and visible target/radar views are distinct; pickup/parent leave-world preserves owners while pausing root simulation; spawn publication is once per incarnation. `GpuWorldState` is spatial-only for live objects. Production PhysicsScript and Animation loading shares a narrow `DatCollection`-backed compatibility reader for retail's inherited blocking-particle payload, including mesh-side preloading; the proven concurrent-safe DatReaderWriter 2.1.7 read path avoids blocking update-thread effects behind streaming. Typed-table selection preserves DAT order and retail's first `intensity <= Mod` boundary, and live `PhysicsDesc` effect defaults replace rather than fall back to Setup. The Step 4 scheduler is now one serial FIFO per owner with duplicate stacking, catch-up dispatch, deterministic delayed `CallPES`, complete hook-router fan-out, update-frame clock publication, retail cell/Frozen eligibility, and structural rejection of malformed zero-time recursive DAT chains without rejecting valid timed weather loops. `EntityEffectController` retains pre-create F754/F755 in one mixed per-GUID FIFO, resolves local identity only through `LiveEntityRuntime`, replays once only after the canonical owner is fully ready and in-world, drops later plays while that existing owner is cell-less, routes attached-child updates through the eligible parent, and replaces/clears the live SoundTable on every PhysicsDesc application. Step 5 publishes current rigid root/indexed-part poses after animation and recursively composed held-child transforms, keeping render-only Setup scale out of particle and holding-location anchors; it then drains animation hooks, owner PhysicsScripts, moving emitters/lights, and particle simulation in fixed order. Normal/blocking/anonymous logical emitter identity is exact, including Stop retaining a blocking ID until final-particle retirement; moving and held Setup lights follow their current roots and share retail's PhysicsState Lighting/SetLight transition state; world-released versus parent-local particle behavior is preserved; missing emitter DAT produces an actionable diagnostic and no invented fallback. Drawable meshes no longer collapse stable Setup-part indices; nested attachments update parent-before-child, recursively withdraw on ancestor pose loss, and recover the complete descendant chain only on real publication edges. Loaded↔pending projection edges skip particle update/draw while retaining original absolute creation times and logical identity, and withdraw light presentation; re-entry evaluates elapsed state once without backlog emission or recreating an effect owner. Scripts, particles, lights, translucency, audio, and teardown all share canonical `WorldEntity.Id`; static allocators fail fast before namespace wrap. IA-7, AD-14, TS-10, TS-11, TS-12, TS-13, and AP-67 are retired; AD-32 now covers only the remaining non-effect, non-Parent pre-create packet families, and AD-43 registers the corrupt-DAT zero-time-cycle safety boundary. - **✓ M2 local attack receive funnel (live-gated 2026-07-15).** Retail `ExecuteAttack` only sends the request; ACE chooses the concrete melee/missile action and returns it in a non-autonomous mt-0 `UpdateMotion`. The local branch now runs that state through the same constructor-defaulted `MoveToInterpretedState` funnel and 15-bit action-stamp gate as remotes, then applies sticky/long-jump tails. The old local-only direct `Commands[]` replay is deleted. Shared conversion lives in `InboundInterpretedMotionFactory`; research: `docs/research/2026-07-11-local-combat-motion-pseudocode.md`. - **✓ M2 basic retail combat bar (live-gated 2026-07-15).** Production mounts authored `gmCombatUI` LayoutDesc `0x21000073`, shows it only for Melee/Missile, and routes mouse plus keyboard through one `CombatAttackController`. Corrections include the authored wider centered dark-red child range (accepted IA-20), full-width left-to-right bright live attack-charge feedback, exact Speed-left/Power-right justification, silent control-only `AttackDone(ActionCancelled)`, target-frame Keep in View with manual orbit, and persistent corpse motion: the AP-80 velocity-only NPC adaptation can now replace only Ready/Walk/Run, never authoritative Dead/actions. `CombatTargetController` ports the selection-cleared AutoTarget consumer, so a selected creature's authoritative Dead motion clears it and selects the nearest eligible creature when enabled. The 2026-07-12 replacement-corpse correction unifies both multi-frame and static/reactive spawns behind retail's CreateObject lifecycle: apply the wire's Dead state while detached, then `MotionTableManager::HandleEnterWorld` strips Ready→Dead links before the first in-world tick; multiple corpses remaining fallen passed the live user gate that day. Follow-up #205 makes the toolbar read the final canonical selection after a reentrant Auto Target notice and restricts automatic candidates to hostile non-player monsters (intentional PK-edge divergence IA-19); that live gate also passed 2026-07-12. Research: `docs/research/2026-07-11-retail-combat-bar-pseudocode.md`, `docs/research/2026-07-11-combat-target-camera-pseudocode.md`, `docs/research/2026-07-12-death-and-auto-target-pseudocode.md`; AP-24/AP-95 retired, AP-80 narrowed, AP-110 narrowed, AP-112 records the remaining attack-start and exact trained-Recklessness seams. -- **M3 cast lifecycle + retained magic UI implemented 2026-07-15; visual gates pending.** `SpellCastingController` ports retail's immediate targeted/untargeted request boundary, component preflight, target eligibility, movement stop, and shared UI-busy lifetime while leaving turn/cast/fizzle/impact outcomes authoritative to ACE. Formula preflight reads decrypted portal.dat formulas, the SCID↔WCID map, canonical account randomization, exact school-focus map, infused-magic properties, and scarab-only substitution. Complete live enchantment packets preserve spell/layer identity, all StatMod fields, client-clock normalization, and exact purge buckets. Authored retained surfaces now include the Magic page of `gmCombatUI`, all eight favorite tabs, equipped caster endowment, `gmSpellbookUI`, `gmSpellComponentUI`, both `gmEffectsUI` windows, and the floating Helpful/Harmful effect indicators. Spell rows resolve UIItem prototype `0x10000343`; the component page now instantiates LayoutDesc `0x21000033` category/component templates `0x10000466/67`, including localized headings, exact row textures and columns, owned/desired counts, selection, and the 0..5000 desired-value contract. The authored scrollbars, exact filters/display ordering, learned-spell drag, and shared server-authoritative delete dialog are connected. `RetailPanelUiController` ports the shared one-active-panel lifecycle so toolbar and indicator launchers converge on one owner. #L.2/#L.3 close and #L.4/AP-110 narrow. Research: `docs/research/2026-07-15-retail-magic-ui-and-casting-pseudocode.md`. +- **M3 cast lifecycle + retained magic UI implemented 2026-07-15; visual gates pending.** `SpellCastingController` ports retail's immediate targeted/untargeted request boundary, component preflight, target eligibility, movement stop, and shared UI-busy lifetime while leaving turn/cast/fizzle/impact outcomes authoritative to ACE. Formula preflight reads decrypted portal.dat formulas, the SCID↔WCID map, canonical account randomization, exact school-focus map, infused-magic properties, and scarab-only substitution. Complete live enchantment packets preserve spell/layer identity, all StatMod fields, client-clock normalization, and exact purge buckets. Authored retained surfaces now include the Magic page of `gmCombatUI`, all eight favorite tabs, equipped caster endowment, `gmSpellbookUI`, `gmSpellComponentUI`, both `gmEffectsUI` windows, and the floating Helpful/Harmful effect indicators. The favorite bar now ports retail horizontal empty-cell padding, shared UIItem 1–9 shortcut overlays, and the Cast button's three independently reflowed DAT face segments. Spell rows resolve UIItem prototype `0x10000343`; the component page now instantiates LayoutDesc `0x21000033` category/component templates `0x10000466/67`, including localized headings, exact row textures and columns, owned/desired counts, selection, and the 0..5000 desired-value contract. The authored scrollbars, exact filters/display ordering, learned-spell drag, and shared server-authoritative delete dialog are connected. `RetailPanelUiController` ports the shared one-active-panel lifecycle so toolbar and indicator launchers converge on one owner. #L.2/#L.3 close and #L.4/AP-110 narrow. Research: `docs/research/2026-07-15-retail-magic-ui-and-casting-pseudocode.md`. - **Retail client command families implemented 2026-07-13; corrective live gate pending.** One shared typed catalog now separates retail client actions from ACE administrator commands for both chat backends. Named-decomp ports cover recall/house/PK travel; age/birth; framerate, lock, version, location, corpse, and die confirmation; clear plus named/automatic UI layouts; AFK/consent; emotes; friends; squelch/filter/message types; and fill-components. App owns execution, Core owns authoritative friends/squelch state, Core.Net owns exact UIQueue/ControlQueue packets. Confirmation reuse now ports retail `DialogFactory` contexts, queue groups/priority, fresh DAT roots, property results, callbacks/close notices, and server abort handling; `/die`, gameplay request tuples, and guarded item-use prompts share its type-1 LayoutDesc `0x2100003C` presenter. The first live gate passed the family except for raw suicide-success code `0x004A` and the title-bar-only FPS presentation; the correction maps retail's text and mounts SmartBox element `0x10000047` with live two-decimal `FPS`/`DEG`. #L.6 is closed; TS-31/TS-47 are narrowed. Research: `docs/research/2026-07-13-retail-client-command-families-pseudocode.md`, `docs/research/2026-07-13-retail-dialog-factory-pseudocode.md`. - **✓ SHIPPED — Character window** (`LayoutDesc 0x2100002E`, `CharacterStatController`, 2026-06-26, same branch). **Visually user-confirmed 2026-06-26 — Attributes tab reads as retail.** Three tabs, header (name/heritage/PK), large-gold level number (dat font, `largeDatFont` 18px), "Total Experience (XP):" + "XP for next level:" captions, 9-row attribute list (icons + right-aligned values + Health/Stamina/Mana vitals), click-to-select (top/bottom selection bars + footer State-B "{Attr}: {value}" / "Experience To Raise: Infinity!" + affordability-gated raise triangles), centered footer. User noted "still needs some polish for later" — deferred to Issue #158. - **✓ CORRECTIVE PORT — live character experience qualities** (2026-07-13, #217; connected gate passed). Added retail `PrivateUpdatePropertyInt64 (0x02CF)` so Total Experience, the level-progress meter, and Skills' Unassigned Experience refresh from the same authoritative server update; Total XP value alignment now matches the authored right-justified layout. diff --git a/docs/research/2026-07-15-retail-magic-ui-and-casting-pseudocode.md b/docs/research/2026-07-15-retail-magic-ui-and-casting-pseudocode.md index 4a0cb683..9669a5d0 100644 --- a/docs/research/2026-07-15-retail-magic-ui-and-casting-pseudocode.md +++ b/docs/research/2026-07-15-retail-magic-ui-and-casting-pseudocode.md @@ -165,6 +165,43 @@ Selecting a spell updates its name and current spell. Cast invokes retail spell input actions change tab/selection; the cast-current action casts the current selection. Favorite edits are sent to ACE. +The visible row is still an ordinary `UIElement_ItemList`; it is not a +spell-specific strip painted by `gmSpellcastingUI`: + +```text +UIElement_ItemList::UpdateEmptySlots @ 0x004E3700 + if list is visible and FixedSlots (0x10000015) == -1: + read row/column constraints 0x5F and 0x60 + when the horizontal constraint is unbounded: + occupiedWidth = itemCount * cellWidth + append floor((listWidth - occupiedWidth) / cellWidth) empty UIItems + when the list shrinks, remove only trailing UIItems in ItemSlot_Empty state + never remove an occupied trailing item merely because it exceeds the viewport + +SpellCastSubMenu::UpdateShortcutOverlays @ 0x004C5BE0 + for each UIItem in list order: + if index < 9: SetShortcutNum(item, index, ghosted=false) + else: SetShortcutNum(item, -1, ghosted=false) + +UIElement_UIItem::SetShortcutNum @ 0x004E1590 + occupied spell shortcut -> regular digit array 0x10000042 + empty padding cell -> empty digit array 0x1000005E +``` + +Thus the first nine visible cells use the same blue 1–9 UIItem overlays as the +main status toolbar, including empty cells, while later cells retain only the +authored `ItemSlot_Empty` background. The count is based on the reflowed list +width (639 pixels at the 800-pixel design parent, 439 pixels inside the live +600-pixel combat page, yielding 13 whole 32-pixel cells). + +The Cast button is likewise fully DAT-authored rather than a synthesized red +rectangle. Element `0x100000B2` is 75 by 32 pixels and its face is three stateful +children authored against a 79-pixel design parent: left `0..31`, middle +`32..46`, and right `47..78`. Apply each child's independent retail edge policy +after the button reflows; at 75 pixels they become `0..31`, `32..42`, and +`43..74`. Collapsing child-authored button media to the first child renders only +the left cap and is not retail behavior. + Outbound character events (`CM_Character`): ```text diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs index 187f8c31..9e8d7e7f 100644 --- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs +++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs @@ -552,8 +552,12 @@ public static class DatWidgetFactory // Spellbook school/level filters are Type-1 buttons with no parent media: // their 13x13 child carries Normal/Highlight art. Keep that child as the // face of this retained leaf instead of consuming and losing it. - ElementInfo? face = info.StateMedia.Count == 0 - ? FindStatefulFaceChild(info) + ElementInfo[] authoredFaces = info.StateMedia.Count == 0 + ? FindStatefulFaceChildren(info) + : []; + ElementInfo? face = authoredFaces.Length == 1 ? authoredFaces[0] : null; + IReadOnlyList? faceSegments = authoredFaces.Length > 1 + ? authoredFaces : null; string? label = ResolveAuthoredString(info, stringResolve); @@ -576,7 +580,7 @@ public static class DatWidgetFactory if (labelInfo.FontDid != 0u && fontResolve is not null) labelFont = fontResolve(labelInfo.FontDid) ?? elementFont; - var button = new UiButton(info, resolve, face) + var button = new UiButton(info, resolve, face, faceSegments) { Label = label, LabelFont = labelFont, @@ -638,11 +642,16 @@ public static class DatWidgetFactory } private static ElementInfo? FindStatefulFaceChild(ElementInfo info) - => info.Children.FirstOrDefault(child => + => FindStatefulFaceChildren(info).FirstOrDefault(); + + private static ElementInfo[] FindStatefulFaceChildren(ElementInfo info) + => info.Children.Where(child => child.StateMedia.Count != 0 && child.StateMedia.Keys.Any(childState => info.States.Values.Any(parentState => - string.Equals(parentState.Name, childState, StringComparison.Ordinal)))); + string.Equals(parentState.Name, childState, StringComparison.Ordinal)))) + .OrderBy(child => child.ReadOrder) + .ToArray(); private static string? ResolveAuthoredString( ElementInfo info, diff --git a/src/AcDream.App/UI/Layout/SpellcastingUiController.cs b/src/AcDream.App/UI/Layout/SpellcastingUiController.cs index c1e98500..669f4a73 100644 --- a/src/AcDream.App/UI/Layout/SpellcastingUiController.cs +++ b/src/AcDream.App/UI/Layout/SpellcastingUiController.cs @@ -45,6 +45,7 @@ public sealed class SpellcastingUiController : IRetainedPanelController private readonly Action _useItem; private readonly Action? _addFavorite; private readonly Action? _removeFavorite; + private readonly UiShortcutSlotGraphics? _shortcutGraphics; private readonly UiElement[] _tabs; private readonly UiElement[] _groups; private readonly UiItemList?[] _lists; @@ -77,7 +78,8 @@ public sealed class SpellcastingUiController : IRetainedPanelController UiElement[] groups, UiItemList?[] lists, UiButton cast, - UiElement endowmentHost) + UiElement endowmentHost, + UiShortcutSlotGraphics? shortcutGraphics) { _spellbook = spellbook; _casting = casting; @@ -89,6 +91,7 @@ public sealed class SpellcastingUiController : IRetainedPanelController _useItem = useItem; _addFavorite = addFavorite; _removeFavorite = removeFavorite; + _shortcutGraphics = shortcutGraphics; _tabs = tabs; _groups = groups; _lists = lists; @@ -139,7 +142,8 @@ public sealed class SpellcastingUiController : IRetainedPanelController Action useItem, SelectionState selection, Action? addFavorite, - Action? removeFavorite) + Action? removeFavorite, + UiShortcutSlotGraphics? shortcutGraphics = null) { if (layout.FindElement(CastButtonId) is not UiButton cast || layout.FindElement(EndowmentId) is not { } endowmentHost) @@ -162,7 +166,7 @@ public sealed class SpellcastingUiController : IRetainedPanelController layout, spellbook, casting, objects, playerGuid, resolveSpellIcon, resolveItemDragIcon, useItem, selection, addFavorite, removeFavorite, - tabs, groups, lists, cast, endowmentHost); + tabs, groups, lists, cast, endowmentHost, shortcutGraphics); } public void AddFavorite(uint spellId) @@ -278,14 +282,17 @@ public sealed class SpellcastingUiController : IRetainedPanelController list.CatalogDropped = (payload, x, _) => { if (payload is SpellbookShortcutDragPayload shortcut) - DropSpellbookShortcut(shortcut, targetTab, DropPosition(list, x)); + DropSpellbookShortcut( + shortcut, targetTab, DropPosition(list, x, favorites.Count)); }; using (list.DeferLayout()) { list.Flush(); - list.Columns = 9; + list.SingleRow = true; list.CellWidth = 32f; list.CellHeight = 32f; + list.EmptySlotFactory = () => CreateEmptyFavoriteSlot(list, targetTab); + list.FillVisibleEmptySlots = true; foreach (uint spellId in favorites) { uint id = spellId; @@ -313,10 +320,59 @@ public sealed class SpellcastingUiController : IRetainedPanelController list.AddItem(slot); } } + StampShortcutOverlays(list); } SelectTab(_activeTab); } + private UiCatalogSlot CreateEmptyFavoriteSlot(UiItemList list, int targetTab) + { + int shortcutIndex = list.GetNumUIItems(); + UiCatalogSlot? slot = null; + slot = new UiCatalogSlot + { + SpriteResolve = list.SpriteResolve, + EmptySprite = _shortcutGraphics is { EmptySprite: > 0u } graphics + ? graphics.EmptySprite + : 0x060074CFu, + Dropped = payload => + { + int position = Math.Max(0, list.IndexOf(slot!)); + int favoriteCount = _spellbook.GetFavorites(targetTab).Count; + position = Math.Min(position, favoriteCount); + if (payload is SpellFavoriteDragPayload favorite) + DropFavorite(favorite, targetTab, position); + else if (payload is SpellbookShortcutDragPayload shortcut) + DropSpellbookShortcut(shortcut, targetTab, position); + }, + }; + ConfigureShortcutOverlay(slot, shortcutIndex); + return slot; + } + + private void StampShortcutOverlays(UiItemList list) + { + for (int i = 0; i < list.GetNumUIItems(); i++) + { + UiItemSlot? slot = list.GetItem(i); + if (slot is null) continue; + ConfigureShortcutOverlay(slot, i); + } + } + + private void ConfigureShortcutOverlay(UiItemSlot slot, int index) + { + slot.RegularDigits = _shortcutGraphics?.RegularDigits; + slot.GhostedDigits = _shortcutGraphics?.GhostedDigits; + slot.EmptyDigits = _shortcutGraphics?.EmptyDigits; + if (_shortcutGraphics is { EmptySprite: > 0u } graphics) + slot.EmptySprite = graphics.EmptySprite; + if (index < 9) + slot.SetShortcutNum(index, ghosted: false); + else + slot.ClearShortcutNum(); + } + private void BeginFavoriteDrag(SpellFavoriteDragPayload payload) => _removeFavorite?.Invoke(payload.SourceTab, payload.SpellId); @@ -343,12 +399,12 @@ public sealed class SpellcastingUiController : IRetainedPanelController _selected[targetTab] = payload.SpellId; } - private static int DropPosition(UiItemList list, int localX) + private static int DropPosition(UiItemList list, int localX, int favoriteCount) { int position = list.CellWidth <= 0f - ? list.GetNumUIItems() + ? favoriteCount : (int)MathF.Floor(MathF.Max(0f, localX) / list.CellWidth); - return Math.Clamp(position, 0, list.GetNumUIItems()); + return Math.Clamp(position, 0, favoriteCount); } private void OnSpellbookChanged() => _favoritesDirty = true; @@ -381,9 +437,9 @@ public sealed class SpellcastingUiController : IRetainedPanelController for (int i = 0; i < list.GetNumUIItems(); i++) if (list.GetItem(i) is UiCatalogSlot slot) slot.Selected = slot.EntryId == _selected[tab]; + } _endowmentSlot.Selected = _endowmentItemId != 0u && _endowmentSelected[_activeTab]; - } } private void OnSelectionChanged(SelectionTransition _) => UpdateCastAvailability(); diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 0d1c04c4..9d98eadd 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -167,6 +167,7 @@ public sealed class RetailUiRuntime : IDisposable private GameplayConfirmationController? _gameplayConfirmationController; private RetailItemConfirmationController? _itemConfirmationController; private RetailSkillTrainingConfirmationController? _skillTrainingConfirmationController; + private UiShortcutSlotGraphics? _shortcutSlotGraphics; private bool _disposed; private RetailUiRuntime(RetailUiRuntimeBindings bindings) @@ -558,11 +559,13 @@ public sealed class RetailUiRuntime : IDisposable return; } - var (regular, ghosted, empty) = LoadToolbarDigits(); + UiShortcutSlotGraphics shortcutGraphics = LoadShortcutSlotGraphics(); ToolbarRuntimeBindings b = _bindings.Toolbar; ToolbarController = Layout.ToolbarController.Bind( layout, b.Objects, b.Shortcuts, b.ResolveIcon, b.UseItem, b.Combat, - regular, ghosted, empty, b.ItemInteraction, b.SendAddShortcut, b.SendRemoveShortcut, + shortcutGraphics.RegularDigits, shortcutGraphics.GhostedDigits, + shortcutGraphics.EmptyDigits, b.ItemInteraction, + b.SendAddShortcut, b.SendRemoveShortcut, toggleCombat: b.ToggleCombat, selectItem: guid => b.Selection.Select(guid, SelectionChangeSource.Toolbar), selectedObjectId: () => b.Selection.SelectedObjectId ?? 0u, @@ -671,7 +674,8 @@ public sealed class RetailUiRuntime : IDisposable _bindings.Magic.UseItem, _bindings.Magic.Selection, _bindings.Magic.AddFavorite, - _bindings.Magic.RemoveFavorite); + _bindings.Magic.RemoveFavorite, + LoadShortcutSlotGraphics()); if (spellcasting is null) Console.WriteLine("[M3] spellcasting: required controls missing in LayoutDesc 0x21000073."); @@ -1012,9 +1016,13 @@ public sealed class RetailUiRuntime : IDisposable $"[UI] retail DialogFactory from LayoutDesc 0x{layoutId:X8}; confirmation root 0x15."); } - private (uint[] Regular, uint[] Ghosted, uint[]? Empty) LoadToolbarDigits() + private UiShortcutSlotGraphics LoadShortcutSlotGraphics() { + if (_shortcutSlotGraphics is not null) + return _shortcutSlotGraphics; + uint[]? regular = null, ghosted = null, empty = null; + uint emptySprite = 0u; lock (_bindings.Assets.DatLock) { var layout = _bindings.Assets.Dats.Get(0x21000037u); @@ -1031,6 +1039,10 @@ public sealed class RetailUiRuntime : IDisposable && emptyComposite.Children.TryGetValue(0x1000034Au, out var emptyNumber) && emptyNumber.StateDesc?.Properties is { } emptyProps) empty = ReadDataIds(emptyProps, 0x1000005Eu); + emptySprite = ItemListCellTemplate.ResolveEmptySprite( + _bindings.Assets.Dats, + CombatUiController.LayoutId, + SpellcastingUiController.FavoriteListId); } regular ??= @@ -1043,8 +1055,13 @@ public sealed class RetailUiRuntime : IDisposable 0x06001ACCu, 0x06001ACDu, 0x06001ACEu, 0x06001ACFu, 0x06001AD0u, 0x06001AD1u, 0x06001AD2u, 0x06001AD3u, 0x06001AD4u, ]; - Console.WriteLine($"[D.5.1] toolbar digit arrays ready: regular={regular.Length}, ghosted={ghosted.Length}, empty={empty?.Length ?? 0} entries."); - return (regular, ghosted, empty); + _shortcutSlotGraphics = new UiShortcutSlotGraphics( + regular, ghosted, empty, emptySprite); + Console.WriteLine( + $"[D.5.1] shared shortcut graphics ready: regular={regular.Length}, " + + $"ghosted={ghosted.Length}, empty={empty?.Length ?? 0}, " + + $"slot=0x{emptySprite:X8}."); + return _shortcutSlotGraphics; } private static uint[]? ReadDataIds( diff --git a/src/AcDream.App/UI/UiButton.cs b/src/AcDream.App/UI/UiButton.cs index 1dfc9c75..e642ccc4 100644 --- a/src/AcDream.App/UI/UiButton.cs +++ b/src/AcDream.App/UI/UiButton.cs @@ -34,6 +34,7 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful { private readonly ElementInfo _info; private readonly ElementInfo _mediaInfo; + private readonly FaceSegment[] _faceSegments; private readonly Func _resolve; private readonly HashSet _availableStates = new(); private bool _pressed; @@ -175,12 +176,12 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful if (stateId == UiStateInfo.DirectStateId) { - if (!_mediaInfo.States.ContainsKey(stateId) && !_mediaInfo.StateMedia.ContainsKey("")) + if (!TryFindState(stateId, out _) && !HasStateMedia("")) return false; ActiveState = ""; return true; } - if (_mediaInfo.States.TryGetValue(stateId, out var state)) + if (TryFindState(stateId, out var state)) { ActiveState = state.Name; return true; @@ -188,7 +189,7 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful string stateName = UiButtonStateMachine.StateName(stateId); if (string.IsNullOrEmpty(stateName)) stateName = RetailUiStateIds.StateName(stateId); - if (!string.IsNullOrEmpty(stateName) && _mediaInfo.StateMedia.ContainsKey(stateName)) + if (!string.IsNullOrEmpty(stateName) && HasStateMedia(stateName)) { ActiveState = stateName; return true; @@ -202,10 +203,14 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful public UiButton( ElementInfo info, Func resolve, - ElementInfo? mediaInfo = null) + ElementInfo? mediaInfo = null, + IReadOnlyList? faceSegments = null) { - _info = info; + _info = info; _mediaInfo = mediaInfo ?? info; + _faceSegments = faceSegments is null + ? [] + : faceSegments.Select(static segment => new FaceSegment(segment)).ToArray(); _resolve = resolve; ClickThrough = false; // buttons are interactive — opt OUT of click-through @@ -213,9 +218,11 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful // Retail layouts commonly declare an empty Normal_pressed descriptor while // supplying art only for Normal/Highlight. Treating that property-only state // as drawable briefly blanks the button during mouse-down. - foreach (string stateName in _mediaInfo.StateMedia.Keys) - if (UiButtonStateMachine.TryStateId(stateName, out uint stateId)) - _availableStates.Add(stateId); + if (_faceSegments.Length == 0) + AddAvailableStates(_mediaInfo); + else + foreach (FaceSegment segment in _faceSegments) + AddAvailableStates(segment.Info); ToggleBehavior = info.TryGetEffectiveBool(0x0Bu, out bool toggle) && toggle; RolloverEnabled = info.TryGetEffectiveBool(0x13u, out bool rollover) && rollover; @@ -233,7 +240,7 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful // DefaultStateName wins; else "Normal" if that state has a sprite; else DirectState (""). if (!string.IsNullOrEmpty(info.DefaultStateName)) ActiveState = info.DefaultStateName; - else if (_mediaInfo.StateMedia.ContainsKey("Normal")) + else if (HasStateMedia("Normal")) ActiveState = "Normal"; // else ActiveState stays "" (DirectState) @@ -258,24 +265,32 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful /// Returns 0 if neither exists. /// Mirrors . /// - private uint ActiveFile() - => _mediaInfo.StateMedia.TryGetValue(ActiveState, out var m) ? m.File - : _mediaInfo.StateMedia.TryGetValue("", out var d) ? d.File : 0u; + private uint ActiveFile(ElementInfo mediaInfo) + => mediaInfo.StateMedia.TryGetValue(ActiveState, out var m) ? m.File + : mediaInfo.StateMedia.TryGetValue("", out var d) ? d.File : 0u; protected override void OnDraw(UiRenderContext ctx) { - uint file = ActiveFile(); - if (file != 0) + if (_faceSegments.Length != 0) { - var (tex, tw, th) = _resolve(file); - if (tex != 0 && tw != 0 && th != 0) + foreach (FaceSegment segment in _faceSegments) + DrawFace(ctx, ActiveFile(segment.Info), segment.Rect(Width, Height)); + } + else + { + uint file = ActiveFile(_mediaInfo); + if (file != 0) { - // Tiled draw — same call shape as UiDatElement.OnDraw (UV-repeat; GL_REPEAT-wrapped - // UI texture). Matches ImgTex::TileCSI; no Stretch mode exists. - float faceWidth = FaceWidth > 0f ? FaceWidth : Width; - float faceHeight = FaceHeight > 0f ? FaceHeight : Height; - ctx.DrawSprite(tex, FaceLeft, FaceTop, faceWidth, faceHeight, - 0, 0, faceWidth / tw, faceHeight / th, Vector4.One); + var (tex, tw, th) = _resolve(file); + if (tex != 0 && tw != 0 && th != 0) + { + // Tiled draw — same call shape as UiDatElement.OnDraw (UV-repeat; GL_REPEAT-wrapped + // UI texture). Matches ImgTex::TileCSI; no Stretch mode exists. + float faceWidth = FaceWidth > 0f ? FaceWidth : Width; + float faceHeight = FaceHeight > 0f ? FaceHeight : Height; + ctx.DrawSprite(tex, FaceLeft, FaceTop, faceWidth, faceHeight, + 0, 0, faceWidth / tw, faceHeight / th, Vector4.One); + } } } @@ -302,6 +317,79 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful } } + private void DrawFace(UiRenderContext ctx, uint file, UiPixelRect rect) + { + if (file == 0 || rect.Width <= 0 || rect.Height <= 0) + return; + var (texture, textureWidth, textureHeight) = _resolve(file); + if (texture == 0 || textureWidth == 0 || textureHeight == 0) + return; + + // Same tiled/cropped media path as UiDatElement. Segment geometry is + // first reflowed by its own four-edge retail layout policy. + ctx.DrawSprite(texture, rect.X0, rect.Y0, rect.Width, rect.Height, + 0f, 0f, (float)rect.Width / textureWidth, (float)rect.Height / textureHeight, + Vector4.One); + } + + private void AddAvailableStates(ElementInfo mediaInfo) + { + foreach (string stateName in mediaInfo.StateMedia.Keys) + if (UiButtonStateMachine.TryStateId(stateName, out uint stateId)) + _availableStates.Add(stateId); + } + + private bool HasStateMedia(string stateName) + { + if (_faceSegments.Length == 0) + return _mediaInfo.StateMedia.ContainsKey(stateName); + foreach (FaceSegment segment in _faceSegments) + if (segment.Info.StateMedia.ContainsKey(stateName)) + return true; + return false; + } + + private bool TryFindState(uint stateId, out UiStateInfo state) + { + if (_faceSegments.Length == 0) + return _mediaInfo.States.TryGetValue(stateId, out state!); + foreach (FaceSegment segment in _faceSegments) + if (segment.Info.States.TryGetValue(stateId, out state!)) + return true; + state = null!; + return false; + } + + private sealed class FaceSegment + { + private readonly UiPixelRect _original; + private readonly UiLayoutPolicy? _layout; + + public FaceSegment(ElementInfo info) + { + Info = info; + _original = UiPixelRect.FromPositionAndSize( + (int)info.X, (int)info.Y, (int)info.Width, (int)info.Height); + if (info.HasOriginalParentSize) + _layout = new UiLayoutPolicy( + info.Left, info.Top, info.Right, info.Bottom, + _original, + UiPixelRect.FromPositionAndSize( + 0, 0, (int)info.OriginalParentWidth, (int)info.OriginalParentHeight)); + } + + public ElementInfo Info { get; } + + public UiPixelRect Rect(float parentWidth, float parentHeight) + { + if (_layout is null) + return _original; + return _layout.Apply( + _original, + UiPixelRect.FromPositionAndSize(0, 0, (int)parentWidth, (int)parentHeight)); + } + } + public override bool OnEvent(in UiEvent e) { switch (e.Type) @@ -393,6 +481,11 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful UpdateVisualState(); } + internal int FaceSegmentCount => _faceSegments.Length; + + internal IReadOnlyList FaceSegmentRectsForTest() + => _faceSegments.Select(segment => segment.Rect(Width, Height)).ToArray(); + public void OnGlobalUiTime(double nowSeconds) { if (!_hotClicking || !HotClickEnabled) diff --git a/src/AcDream.App/UI/UiCatalogSlot.cs b/src/AcDream.App/UI/UiCatalogSlot.cs index eaa27bc0..fc59a72e 100644 --- a/src/AcDream.App/UI/UiCatalogSlot.cs +++ b/src/AcDream.App/UI/UiCatalogSlot.cs @@ -36,6 +36,9 @@ public sealed class UiCatalogSlot : UiItemSlot public Action? DragEnded { get; init; } public Action? Dropped { get; init; } + protected override bool IsShortcutOccupied => EntryId != 0u; + public override bool IsEmptySlot => EntryId == 0u; + public override string? GetTooltipText() => string.IsNullOrWhiteSpace(Label) ? null : Label; public override bool IsDragSource => CatalogDragPayload is not null; @@ -102,6 +105,11 @@ public sealed class UiCatalogSlot : UiItemSlot if (CatalogOverlayTexture != 0) ctx.DrawSprite(CatalogOverlayTexture, IconLeft, IconTop, iconWidth, iconHeight, 0f, 0f, 1f, 1f, Vector4.One); + // Spell favorites are UIElement_UIItems too. Retail's + // SpellCastSubMenu::UpdateShortcutOverlays @ 0x004C5BE0 calls the same + // SetShortcutNum path used by physical toolbar shortcuts. + DrawShortcutOverlay(ctx); + if (!SelectionBehindContent) DrawSelection(ctx); diff --git a/src/AcDream.App/UI/UiItemList.cs b/src/AcDream.App/UI/UiItemList.cs index aaeddfa1..b4b7ab7a 100644 --- a/src/AcDream.App/UI/UiItemList.cs +++ b/src/AcDream.App/UI/UiItemList.cs @@ -82,6 +82,10 @@ public sealed class UiItemList : UiElement public int GetNumUIItems() => _cells.Count; + /// Returns this cell's current retained-list position, matching + /// UIElement_ListBox::WhatNum. -1 means the cell is not owned by this list. + public int IndexOf(UiItemSlot cell) => _cells.IndexOf(cell); + public UiItemSlot? GetItem(int index) => index >= 0 && index < _cells.Count ? _cells[index] : null; @@ -128,6 +132,24 @@ public sealed class UiItemList : UiElement /// Fixed cell height in grid mode (pairs with CellWidth). public float CellHeight { get; set; } + /// + /// Lay cells left-to-right in one row. Retail favorite spell lists use the + /// horizontal UpdateEmptySlots branch (row-count attribute 0x5F is -1), so + /// the number of visible cells is determined by width rather than the nine + /// shortcut-number overlays. + /// + public bool SingleRow { get; set; } + + /// + /// Maintain a tail of empty UIItems that fills the visible horizontal extent. + /// Port of UIElement_ItemList::UpdateEmptySlots @ 0x004E3700. + /// + public bool FillVisibleEmptySlots { get; set; } + + /// Creates an empty cell for . + /// Null uses a plain physical UiItemSlot. + public Func? EmptySlotFactory { get; set; } + /// Whole rows needed for cells in a grid of /// columns. public static int RowCount(int cellCount, int columns) @@ -177,7 +199,11 @@ public sealed class UiItemList : UiElement return; } - int cols = Columns < 1 ? 1 : Columns; + UpdateEmptySlots(); + + int cols = SingleRow + ? Math.Max(1, _cells.Count) + : Columns < 1 ? 1 : Columns; int cellH = (int)MathF.Round(CellHeight); // Drive the shared scroll model from the current geometry, then re-clamp the offset to @@ -201,6 +227,35 @@ public sealed class UiItemList : UiElement } } + /// + /// Retail's horizontal branch pads to floor(listWidth / cellWidth), and when + /// the viewport shrinks removes only empty cells from the tail. Occupied cells + /// are never discarded merely because they extend beyond the viewport. + /// + private void UpdateEmptySlots() + { + if (!FillVisibleEmptySlots || !SingleRow || CellWidth <= 0f) + return; + + int visibleCellCount = Math.Max(0, (int)MathF.Floor(Width / CellWidth)); + while (_cells.Count < visibleCellCount) + { + UiItemSlot cell = EmptySlotFactory?.Invoke() ?? new UiItemSlot(); + cell.SpriteResolve ??= SpriteResolve; + if (_cellEmptySprite != 0) cell.EmptySprite = _cellEmptySprite; + cell.Anchors = AnchorEdges.None; + _cells.Add(cell); + AddChild(cell); + } + + while (_cells.Count > visibleCellCount && _cells[^1].IsEmptySlot) + { + UiItemSlot cell = _cells[^1]; + _cells.RemoveAt(_cells.Count - 1); + RemoveChild(cell); + } + } + protected override bool ClipsChildren => CellWidth > 0f; public void Flush() diff --git a/src/AcDream.App/UI/UiItemSlot.cs b/src/AcDream.App/UI/UiItemSlot.cs index 9126cad9..0a9a7d58 100644 --- a/src/AcDream.App/UI/UiItemSlot.cs +++ b/src/AcDream.App/UI/UiItemSlot.cs @@ -192,6 +192,17 @@ public class UiItemSlot : UiElement /// (decomp 229481) — else branch when m_elem_Icon->m_state == 0x1000001c (empty). public uint[]? EmptyDigits { get; set; } + /// + /// Whether this retained cell represents an occupied UIItem for shortcut-number + /// presentation. Physical item cells use their weenie id; non-weenie catalog + /// cells override this with their own identity instead of forging an item GUID. + /// + protected virtual bool IsShortcutOccupied => ItemId != 0; + + /// True when this cell is an empty slot that a retail item list may + /// add/remove while maintaining its visible empty-cell tail. + public virtual bool IsEmptySlot => ItemId == 0; + /// Set the slot's shortcut position and ghosted state so the correct digit /// is drawn. Call with index 0..8 for the top row. public void SetShortcutNum(int index, bool ghosted) @@ -213,8 +224,9 @@ public class UiItemSlot : UiElement /// internal uint[]? ActiveDigitArray() { - bool occupied = ItemId != 0; - return occupied ? (ShortcutGhosted ? GhostedDigits : RegularDigits) : EmptyDigits; + return IsShortcutOccupied + ? (ShortcutGhosted ? GhostedDigits : RegularDigits) + : EmptyDigits; } // ── Events / draw ───────────────────────────────────────────────────────── @@ -296,20 +308,7 @@ public class UiItemSlot : UiElement // empty (ItemId == 0) → background digit set 0x1000005e, stance-independent // Each digit image is corner-baked (glyph in top-left, rest alpha=0); drawn // full-cell Alphablend so the transparent region is invisible. - if (ShortcutNum >= 0 && SpriteResolve is not null) - { - var arr = ActiveDigitArray(); - if (arr is not null && ShortcutNum < arr.Length) - { - uint did = arr[ShortcutNum]; - if (did != 0) - { - var (tex, _, _) = SpriteResolve(did); - if (tex != 0) - ctx.DrawSprite(tex, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One); - } - } - } + DrawShortcutOverlay(ctx); // Container capacity bar — UIElement_UIItem::UpdateCapacityDisplay (0x004e16e0): a vertical // UIElement_Meter (element 0x10000347, 5×30 at x=26,y=1) shown only for container cells @@ -381,4 +380,25 @@ public class UiItemSlot : UiElement } } } + + /// Draws the shared retail shortcut-number layer. Catalog spell cells + /// call this after their icon layer so magic favorites and physical shortcuts + /// use the same UIItem presentation path. + protected void DrawShortcutOverlay(UiRenderContext ctx) + { + if (ShortcutNum < 0 || SpriteResolve is null) + return; + + uint[]? digits = ActiveDigitArray(); + if (digits is null || ShortcutNum >= digits.Length) + return; + + uint did = digits[ShortcutNum]; + if (did == 0) + return; + + var (texture, _, _) = SpriteResolve(did); + if (texture != 0) + ctx.DrawSprite(texture, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One); + } } diff --git a/src/AcDream.App/UI/UiShortcutSlotGraphics.cs b/src/AcDream.App/UI/UiShortcutSlotGraphics.cs new file mode 100644 index 00000000..7d047e57 --- /dev/null +++ b/src/AcDream.App/UI/UiShortcutSlotGraphics.cs @@ -0,0 +1,12 @@ +namespace AcDream.App.UI; + +/// +/// Shared DAT presentation assets for UIItem shortcut cells. Both gmToolbarUI and +/// gmSpellcastingUI use UIElement_UIItem::SetShortcutNum @ 0x004E1590, so their +/// digit arrays and empty-cell prototype must come from one resolved asset set. +/// +public sealed record UiShortcutSlotGraphics( + uint[]? RegularDigits, + uint[]? GhostedDigits, + uint[]? EmptyDigits, + uint EmptySprite); diff --git a/tests/AcDream.App.Tests/UI/Layout/SpellcastingUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/SpellcastingUiControllerTests.cs index 9d55c234..71e4658b 100644 --- a/tests/AcDream.App.Tests/UI/Layout/SpellcastingUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/SpellcastingUiControllerTests.cs @@ -11,6 +11,60 @@ public sealed class SpellcastingUiControllerTests { private static (uint, int, int) NoTex(uint _) => (0u, 0, 0); + [Fact] + public void ImportedFixture_UsesRetailEmptyCells_ShortcutDigits_AndCompleteCastButton() + { + ImportedLayout layout = LayoutImporter.Build( + FixtureLoader.LoadCombatInfos(), NoTex, datFont: null); + ApplyAnchors(layout.Root); + var spellbook = new Spellbook(); + spellbook.OnSpellLearned(42u, 1f); + spellbook.SetFavorite(0, 0, 42u); + var objects = new ClientObjectTable(); + objects.AddOrUpdate(new ClientObject { ObjectId = 1u, Name = "Player" }); + uint[] regular = [101u, 102u, 103u, 104u, 105u, 106u, 107u, 108u, 109u]; + uint[] ghosted = [201u, 202u, 203u, 204u, 205u, 206u, 207u, 208u, 209u]; + uint[] empty = [301u, 302u, 303u, 304u, 305u, 306u, 307u, 308u, 309u]; + var graphics = new UiShortcutSlotGraphics(regular, ghosted, empty, 0x060074CFu); + + using SpellcastingUiController controller = Bind( + layout, spellbook, objects, _ => { }, shortcutGraphics: graphics)!; + + UiElement group = layout.FindElement(0x100000AAu)!; + UiItemList list = Descendants(group).OfType().First(); + Assert.True(list.SingleRow); + Assert.True(list.FillVisibleEmptySlots); + Assert.Equal(439f, list.Width); + Assert.Equal(13, list.GetNumUIItems()); + + UiCatalogSlot favorite = Assert.IsType(list.GetItem(0)); + Assert.Equal(42u, favorite.EntryId); + Assert.Equal(0, favorite.ShortcutNum); + Assert.Same(regular, favorite.ActiveDigitArray()); + + for (int i = 1; i < 9; i++) + { + UiCatalogSlot slot = Assert.IsType(list.GetItem(i)); + Assert.True(slot.IsEmptySlot); + Assert.Equal(i, slot.ShortcutNum); + Assert.Same(empty, slot.ActiveDigitArray()); + } + for (int i = 9; i < 13; i++) + Assert.Equal(-1, list.GetItem(i)!.ShortcutNum); + + var cast = Assert.IsType( + layout.FindElement(SpellcastingUiController.CastButtonId)); + Assert.Equal((525f, 75f), (cast.Left, cast.Width)); + Assert.Equal(3, cast.FaceSegmentCount); + Assert.Equal( + [ + new UiPixelRect(0, 0, 31, 31), + new UiPixelRect(32, 0, 42, 31), + new UiPixelRect(43, 0, 74, 31), + ], + cast.FaceSegmentRectsForTest()); + } + [Fact] public void ImportedFixture_BindsAllTabs_AndTracksEquippedEndowment() { @@ -167,7 +221,8 @@ public sealed class SpellcastingUiControllerTests Spellbook spellbook, ClientObjectTable objects, Action useItem, - Action? addFavorite = null) + Action? addFavorite = null, + UiShortcutSlotGraphics? shortcutGraphics = null) { var casting = new SpellCastingController( spellbook, () => null, () => 1u, () => { }, _ => { }, (_, _) => { }, _ => { }); @@ -178,7 +233,17 @@ public sealed class SpellcastingUiControllerTests useItem, new SelectionState(), addFavorite ?? ((_, _, _) => { }), - (_, _) => { }); + (_, _) => { }, + shortcutGraphics); + } + + private static void ApplyAnchors(UiElement parent) + { + foreach (UiElement child in parent.Children) + { + child.ApplyAnchor(parent.Width, parent.Height); + ApplyAnchors(child); + } } private static IEnumerable Descendants(UiElement root) diff --git a/tests/AcDream.App.Tests/UI/UiItemListTests.cs b/tests/AcDream.App.Tests/UI/UiItemListTests.cs index 3ef8276f..a03925d9 100644 --- a/tests/AcDream.App.Tests/UI/UiItemListTests.cs +++ b/tests/AcDream.App.Tests/UI/UiItemListTests.cs @@ -61,6 +61,38 @@ public class UiItemListTests Assert.Equal((17, 9), (received.Value.X, received.Value.Y)); } + [Fact] + public void RetailHorizontalEmptySlots_FillViewport_AndShrinkOnlyEmptyTail() + { + var list = new UiItemList + { + Width = 130f, + Height = 32f, + CellWidth = 32f, + CellHeight = 32f, + SingleRow = true, + FillVisibleEmptySlots = true, + EmptySlotFactory = () => new UiCatalogSlot(), + }; + list.Flush(); + list.AddItem(new UiCatalogSlot { EntryId = 42u }); + list.LayoutCells(); + + Assert.Equal(4, list.GetNumUIItems()); + Assert.Equal((0f, 32f, 64f, 96f), + (list.GetItem(0)!.Left, list.GetItem(1)!.Left, + list.GetItem(2)!.Left, list.GetItem(3)!.Left)); + + list.Width = 65f; + list.LayoutCells(); + Assert.Equal(2, list.GetNumUIItems()); + Assert.Equal(42u, Assert.IsType(list.GetItem(0)).EntryId); + + list.Width = 16f; + list.LayoutCells(); + Assert.Single(list.Children); // the occupied cell is never discarded + } + [Fact] public void ScrollItemIntoView_moves_only_when_row_is_outside_viewport() {