From 3f3cfdac308489d4d86624a01d0083d802e7afb6 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 17 Jul 2026 09:13:31 +0200 Subject: [PATCH] fix(ui): share primary panel placement Port gmPanelUI's persistent parent placement semantics across Inventory, Character, and Magic while preserving each imported child's content and resize policy. Synchronize typed retained-window handles so drag, switching, close/reopen, and layout persistence all observe one canonical location; keep effect panels independent. User-verified in normal Release. Release build and all 5,770 tests passed with five intentional skips. Co-authored-by: OpenAI Codex --- docs/ISSUES.md | 41 +++++ .../retail-divergence-register.md | 2 +- ...-17-retail-shared-main-panel-pseudocode.md | 113 +++++++++++++ .../UI/Layout/RetailPanelUiController.cs | 158 +++++++++++++++++- src/AcDream.App/UI/RetailUiRuntime.cs | 23 ++- .../UI/Layout/RetailPanelUiControllerTests.cs | 90 ++++++++++ .../UI/RetailWindowLayoutPersistenceTests.cs | 42 +++++ 7 files changed, 460 insertions(+), 9 deletions(-) create mode 100644 docs/research/2026-07-17-retail-shared-main-panel-pseudocode.md diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 7d713970..e9170c75 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -44,6 +44,47 @@ Copy this block when adding a new issue: --- +## #221 — Primary UI panels retained independent window positions + +**Status:** DONE — 2026-07-17 +**Severity:** MEDIUM +**Filed:** 2026-07-17 +**Component:** retained UI / panel lifecycle / persistence + +**Description:** Moving Inventory and then opening Character/Skills or Magic +opened the replacement panel at its own stale position. Retail presents these +as different child contents of one persistent main panel. + +**Root cause / status:** `RetailPanelUiController` already ported +`gmPanelUI`'s one-active-child and restore-previous behavior, but the imported +LayoutDesc children were mounted as independently positioned retained window +frames. The controller now owns one canonical placement for Inventory (7), +Character (11), and Magic (13). Moving any primary child synchronizes its +hidden siblings through `RetailWindowHandle.MoveTo`, so ordinary per-character +layout persistence observes the same position. Switching and closing capture +that canonical placement; Helpful/Harmful effect panels retain their separate +placement and restore-previous behavior. + +**Files:** `src/AcDream.App/UI/Layout/RetailPanelUiController.cs`; +`src/AcDream.App/UI/RetailUiRuntime.cs`; +`tests/AcDream.App.Tests/UI/Layout/RetailPanelUiControllerTests.cs`; +`tests/AcDream.App.Tests/UI/RetailWindowLayoutPersistenceTests.cs`. + +**Research:** +`docs/research/2026-07-17-retail-shared-main-panel-pseudocode.md`. + +**Acceptance:** Connected normal-Release visual gate passed 2026-07-17: +Inventory, Character/Skills, and Spellbook/Components retain one position +across switching, dragging, closing, and reopening. + +**Acceptance:** Open Inventory, move it, then switch directly and after closing +to Character/Skills and Spellbook/Components. Every replacement must open at +the most recently moved main-panel position. Move another panel and repeat in +the opposite direction. Helpful/Harmful effects must not overwrite that +position. + +--- + ## #220 — Local player glides before locomotion animation starts **Status:** DONE 2026-07-17 — user-confirmed connected visual gate diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index cfc0a648..1a6d25bf 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -50,7 +50,7 @@ accepted-divergence entries (#96, #49, #50). | IA-9 | One unified camera matrix for terrain — retail's separate `LScape::update_viewpoint` landscape viewpoint does not exist | `src/AcDream.App/Rendering/TerrainModernRenderer.cs:266` | Phase W T4.2: with one matrix everywhere, viewpoint-desync bugs are unrepresentable — the unification IS the correctness argument | Anything retail derives from the landcell-relative viewpoint (float precision at extreme coords, viewpoint-keyed state) has no analogue; a future port expecting it silently reads the camera | `LScape::update_viewpoint`; `LScape::draw` 0x00506330 | | IA-10 | Transparent groups sorted back-to-front per GROUP by first-instance position (no within-group sort) vs retail per-poly BSP-order draw | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:1364` (comparer :1662) | One MDI call per pass requires group-granularity ordering; per-poly sorting is incompatible with instanced multi-draw; works when group instances are spatially coherent | Spatially spread or interleaved transparent groups composite in the wrong order — popping / wrong see-through layering as the camera moves | retail per-poly BSP-order transparent draw (D3DPolyRender / PView::DrawCells) | | IA-11 | Tier-1 cross-frame batch-classification cache for static entities (retail re-walks part arrays every frame) | `src/AcDream.App/Rendering/Wb/EntityClassificationCache.cs:12` | Issue #53 perf tier; invariants documented (keys = EntityId + OWNING-landblock hint post-**#119** fix `2163308`; invalidation at despawn/LB-unload; mutation audit 2026-05-10) | Key collision or missed invalidation serves one entity another's batches — session-sticky wrong meshes (the #119 broken-stairs/water-barrel symptom) | retail per-frame part-array classification (no cache) | -| IA-12 | UI toolkit mirrors retail behavior from research docs, not a byte-port — keystone.dll is outside decomp coverage; observed constants embedded (drag 3 px, tooltip 1000 ms) | `src/AcDream.App/UI/README.md:3` (window mgr: `UiRoot.cs` RegisterWindow/Show/Hide/Toggle/BringToFront — F12 inventory toggle IS retail-faithful) | keystone.dll has no PDB/decomp; semantics reconstructed from the six `docs/research/retail-ui/` deep-dives, keeping retail's event-type constants so panel switch-cases transplant cleanly | Edge-case input semantics the research under-specified (drag threshold, tooltip timing, focus hand-off, capture corners) differ silently with no oracle to diff against | keystone.dll Device DAT_00837ff4; docs/research/retail-ui/04-input-events.md | +| IA-12 | UI toolkit mirrors retail behavior from research docs, not a byte-port — keystone.dll is outside decomp coverage; observed constants embedded (drag 3 px, tooltip 1000 ms). `gmPanelUI` primary children are independently imported retained frames rather than one physical parent, but `RetailPanelUiController` owns their one canonical placement and exclusive child lifecycle | `src/AcDream.App/UI/README.md:3`; `src/AcDream.App/UI/Layout/RetailPanelUiController.cs` | keystone.dll has no PDB/decomp; semantics are reconstructed from the retail UI deep-dives and named `gmPanelUI` methods. Separate child wrappers preserve each LayoutDesc's content/resize policy while typed handle synchronization gives Inventory/Character/Magic the same persistent parent position | Edge-case input semantics can differ silently; if retail relies on parent-size anchoring across a panel switch, separate child extents may resize differently even though placement and visibility match | `gmPanelUI::SetupChildren` 0x004BC9E0; `RecvNotice_SetPanelVisibility` 0x004BC6F0; docs/research/retail-ui/04-input-events.md | | IA-13 | GameEventType registry deliberately omits event types retail ignores; unknown events fall through unhandled | `src/AcDream.Core.Net/Messages/GameEventType.cs:11` | Retail also ignores them — dropping matches retail by construction | If the "retail ignores X" judgment is wrong for any opcode (or a server mod uses one), the event is silently dropped with no diagnostic pointing at the omission | retail GameEvent dispatch (ignored-event set) | | IA-14 | Rendering + dat-handling base is WorldBuilder's tested port, not a fresh retail-decomp port (Phase N.4/O design stance) | `docs/architecture/worldbuilder-inventory.md` (code at `src/AcDream.{Core,App}/Rendering/Wb/`) | WB visually verified on the AC world, MIT, same stack; known WB↔retail deltas resolved case-by-case — terrain split kept retail `FSplitNESW` (**#51**, pinned by `SplitFormulaDivergenceTest`), scenery drift accepted (AP-31) | A WB-upstream divergence not yet caught ships silently as "our" behavior; guard = the inventory doc's 🟢/🔴 split + per-formula divergence tests | retail decomp per algorithm; `tests/.../SplitFormulaDivergenceTest.cs` | | IA-15 | D.2b gameplay UI is our own `UiHost`/`UiRoot` retained tree, not a byte-port of Keystone. `RetailUiRuntime` owns the production import/mount graph; `RetailWindowManager`/typed handles centralize registry, raise, focus/capture cleanup, lifecycle events, reverse-order grouped controller teardown, removable Silk input subscriptions, schema-v2 per-character/per-resolution automatic layouts, and portable named `saveui/loadui` profiles; `RetailWindowFrame` is the single production/Studio mount contract for imported-chrome and shared-wrapper windows. Production LayoutDesc imports include vitals `0x2100006C`, chat `0x21000006`, toolbar `0x21000016`, character `0x2100002E`, inventory `0x21000023` plus mounted `0x21000024/22/21`, dialog catalog `0x2100003C`, and radar `0x21000074`. The dialog context/queue/callback lifecycle is now a named-client port; only its retained rendering remains under this Keystone adaptation. | `src/AcDream.App/UI/RetailUiRuntime.cs`; `src/AcDream.App/UI/RetailWindowManager.cs`; `src/AcDream.App/UI/RetainedPanelControllerGroup.cs`; `src/AcDream.App/UI/UiHost.cs`; `src/AcDream.App/UI/RetailWindowLayoutPersistence.cs`; `src/AcDream.App/UI/Layout/RetailWindowFrame.cs`; `src/AcDream.App/UI/Layout/RetailDialogFactory.cs`; `src/AcDream.App/UI/Layout/RetailConfirmationDialogView.cs`; `src/AcDream.App/UI/Layout/LayoutImporter.cs`; binding supply in `GameWindow.cs` | Keystone has no matching PDB/decomp, so we preserve its observable ElementDesc/state/input behavior from DAT, named client call sites, and live evidence while using modern retained ownership. Real RenderSurfaces and imported element geometry remain the visual oracle. | Persistence and low-level widget rendering are behaviorally reconstructed from retail semantics rather than a Keystone byte-port; lifecycle edge cases remain constrained by conformance tests | Production LayoutDesc objects; `DialogFactory @ 0x004773C0..0x00478470`; `docs/research/2026-07-13-retail-dialog-factory-pseudocode.md`; Keystone behavior notes in `docs/research/retail-ui/` | diff --git a/docs/research/2026-07-17-retail-shared-main-panel-pseudocode.md b/docs/research/2026-07-17-retail-shared-main-panel-pseudocode.md new file mode 100644 index 00000000..a3e05b47 --- /dev/null +++ b/docs/research/2026-07-17-retail-shared-main-panel-pseudocode.md @@ -0,0 +1,113 @@ +# Retail shared main-panel placement — pseudocode + +**Date:** 2026-07-17 +**Symptom:** moving Inventory and then opening Character/Skills or Magic opens +the new panel at its own stale saved position instead of the position just used. + +## Retail oracle + +Named Sept-2013 retail symbols: + +- `gmPanelUI::SetupChildren @ 0x004BC9E0` +- `gmPanelUI::RecvNotice_SetPanelVisibility @ 0x004BC6F0` +- `gmPanelUI::PostInit @ 0x004BD010` +- `gmPanelUI::ResizeTo @ 0x004BC6E0` + +Verbatim `acclient.h` ownership: + +```text +gmPanelUI : UIElement_Field + SmartArray m_childrenInfoArray + UIElement* m_currentlyShownPanel + UIElement* m_previouslyShownPanel + UIElement* m_pPanelPagesFrame + bool m_bStretchMode + +PanelChildInfo + UIElement* child + uint panelID +``` + +`SetupChildren` resolves each known panel element recursively, stores the +child pointer together with its property-`0x10000029` panel ID, and initially +hides every child. It does not create independent top-level windows. + +## Retail behavior + +```text +SetPanelVisibility(panelId, show): + requested = find m_childrenInfoArray entry by panelId + if requested is missing: + return + + if show: + if requested.child == m_currentlyShownPanel: + requested.child.SetVisible(true) + parent.SetVisible(true) + return + + previous = m_currentlyShownPanel + m_currentlyShownPanel = requested.child + + if previous is visible: + if requested restores previous and previous does not: + m_previouslyShownPanel = previous + else: + m_previouslyShownPanel = null + send SetPanelVisibility(previous.panelID, false) + + requested.child.SetVisible(true) + parent.SetVisible(true) + return + + if requested.child == m_currentlyShownPanel: + requested.child.SetVisible(false) + if m_previouslyShownPanel exists: + send SetPanelVisibility(previous.panelID, true) + m_previouslyShownPanel = null + else: + m_currentlyShownPanel = null + parent.SetVisible(false) + return + + requested.child.SetVisible(false) +``` + +The decisive placement fact is structural: Inventory, Character/Skills, and +Magic are children of one `gmPanelUI`. The switch changes child visibility; +there is no child move and no per-panel top-level position. Dragging the parent +therefore moves the one position subsequently used by every child. + +## acdream port shape + +acdream imports these child LayoutDesc roots independently, so each currently +has its own retained wrapper. `RetailPanelUiController` already ports retail's +one-active-child and restore-previous lifecycle. Complete that logical-parent +ownership by adding one canonical placement for the primary toolbar children +(Inventory `7`, Character `11`, Magic `13`): + +```text +on primary child frame moved: + canonicalPosition = moved frame position + move every other primary child frame to canonicalPosition + +before showing a primary child: + if canonicalPosition exists: + move requested child frame to canonicalPosition + else: + canonicalPosition = requested child frame position + +before hiding the active primary child: + capture its current position as canonicalPosition +``` + +The controller owns this synchronization and subscribes to typed +`RetailWindowHandle.Moved` events. Toolbar commands, keyboard commands, +restore-previous effects, persistence restore, and direct window dragging all +therefore pass through the same owner. Panel-authored size/resize policy stays +with the active child; only the retail parent placement is shared. + +ACE contains no client UI implementation, and the in-tree WorldBuilder +reference contains no `gmPanelUI` port, so neither supplies a competing +behavioral interpretation. The named retail client and verbatim header are the +oracle for this UI ownership rule. diff --git a/src/AcDream.App/UI/Layout/RetailPanelUiController.cs b/src/AcDream.App/UI/Layout/RetailPanelUiController.cs index 738a45a8..50e9e251 100644 --- a/src/AcDream.App/UI/Layout/RetailPanelUiController.cs +++ b/src/AcDream.App/UI/Layout/RetailPanelUiController.cs @@ -9,7 +9,7 @@ namespace AcDream.App.UI.Layout; /// deferred child; this owner applies the same lifecycle to registered retained /// windows without making the toolbar authoritative for non-toolbar panels. /// -public sealed class RetailPanelUiController +public sealed class RetailPanelUiController : IDisposable { public const uint RestorePreviousPropertyId = 0x10000049u; @@ -21,6 +21,9 @@ public sealed class RetailPanelUiController private uint? _activePanel; private uint? _deferredPanel; private bool _applying; + private bool _synchronizingPlacement; + private PanelPlacement? _mainPanelPlacement; + private bool _disposed; public RetailPanelUiController( Func isVisible, @@ -34,21 +37,72 @@ public sealed class RetailPanelUiController public uint? ActivePanelId => _activePanel; + /// + /// Registers one of retail gmPanelUI's primary toolbar children. + /// Inventory, Character, and Magic retain panel-specific content/resize + /// policy but share the one parent placement. A drag of any registered + /// child updates every hidden sibling through typed window handles, so + /// persistence observes the same canonical position too. + /// + public void RegisterMainPanel( + uint panelId, + string windowName, + RetailWindowHandle window, + bool restorePrevious = false) + { + ArgumentNullException.ThrowIfNull(window); + if (!string.Equals(windowName, window.Name, StringComparison.Ordinal)) + throw new ArgumentException( + $"Panel window name '{windowName}' does not match handle '{window.Name}'.", + nameof(windowName)); + + RegisterCore( + panelId, + windowName, + restorePrevious, + window, + sharesMainPanelPlacement: true); + } + /// /// Effective DAT bool property 0x10000049. Retail retains the previous /// panel only when the newly shown panel has this property and the previous /// panel does not. /// public void Register(uint panelId, string windowName, bool restorePrevious = false) + => RegisterCore( + panelId, + windowName, + restorePrevious, + window: null, + sharesMainPanelPlacement: false); + + private void RegisterCore( + uint panelId, + string windowName, + bool restorePrevious, + RetailWindowHandle? window, + bool sharesMainPanelPlacement) { + ObjectDisposedException.ThrowIf(_disposed, this); if (panelId == 0) throw new ArgumentOutOfRangeException(nameof(panelId)); ArgumentException.ThrowIfNullOrWhiteSpace(windowName); if (_byPanel.ContainsKey(panelId) || _byWindow.ContainsKey(windowName)) throw new InvalidOperationException( $"Panel {panelId} or retained window '{windowName}' is already registered."); - _byPanel.Add(panelId, new PanelEntry(windowName, restorePrevious)); + var entry = new PanelEntry( + windowName, + restorePrevious, + window, + sharesMainPanelPlacement); + _byPanel.Add(panelId, entry); _byWindow.Add(windowName, panelId); + if (window is not null) + window.Moved += OnWindowMoved; + + if (sharesMainPanelPlacement && _mainPanelPlacement is { } placement) + MoveWindowTo(entry, placement); if (_isVisible(windowName)) ObserveWindowVisibility(windowName, visible: true); } @@ -89,7 +143,10 @@ public sealed class RetailPanelUiController if (visible) { if (_activePanel == panelId) + { + PrepareMainPanelPlacement(requested); return _show(requested.WindowName); + } uint? previousId = _activePanel; PanelEntry? previous = previousId is uint id @@ -98,6 +155,9 @@ public sealed class RetailPanelUiController ? found : null; + if (previous is { } previousEntry) + CaptureMainPanelPlacement(previousEntry, synchronizeSiblings: true); + _deferredPanel = requested.RestorePrevious && previous is { RestorePrevious: false } ? previousId @@ -105,6 +165,7 @@ public sealed class RetailPanelUiController _activePanel = panelId; if (previous is not null) _hide(previous.Value.WindowName); + PrepareMainPanelPlacement(requested); return _show(requested.WindowName); } @@ -114,6 +175,7 @@ public sealed class RetailPanelUiController return _hide(requested.WindowName); } + CaptureMainPanelPlacement(requested, synchronizeSiblings: true); bool hidden = _hide(requested.WindowName); _activePanel = null; if (_deferredPanel is not uint deferredId @@ -122,6 +184,7 @@ public sealed class RetailPanelUiController _deferredPanel = null; _activePanel = deferredId; + PrepareMainPanelPlacement(deferred); return _show(deferred.WindowName) || hidden; } finally @@ -140,5 +203,94 @@ public sealed class RetailPanelUiController SetPanelVisibility(panelId, visible); } - private readonly record struct PanelEntry(string WindowName, bool RestorePrevious); + private void OnWindowMoved(RetailWindowHandle window) + { + if (_disposed || _synchronizingPlacement) return; + + foreach (PanelEntry entry in _byPanel.Values) + { + if (!entry.SharesMainPanelPlacement + || !ReferenceEquals(entry.Window, window)) + continue; + + _mainPanelPlacement = new PanelPlacement(window.Left, window.Top); + SynchronizeMainPanelSiblings(window); + return; + } + } + + private void CaptureMainPanelPlacement( + PanelEntry entry, + bool synchronizeSiblings) + { + if (!entry.SharesMainPanelPlacement || entry.Window is not { } window) + return; + + _mainPanelPlacement = new PanelPlacement(window.Left, window.Top); + if (synchronizeSiblings) + SynchronizeMainPanelSiblings(window); + } + + private void PrepareMainPanelPlacement(PanelEntry entry) + { + if (!entry.SharesMainPanelPlacement || entry.Window is not { } window) + return; + + if (_mainPanelPlacement is not { } placement) + { + _mainPanelPlacement = new PanelPlacement(window.Left, window.Top); + SynchronizeMainPanelSiblings(window); + return; + } + + MoveWindowTo(entry, placement); + } + + private void SynchronizeMainPanelSiblings(RetailWindowHandle source) + { + if (_mainPanelPlacement is not { } placement) return; + + _synchronizingPlacement = true; + try + { + foreach (PanelEntry sibling in _byPanel.Values) + { + if (!sibling.SharesMainPanelPlacement + || sibling.Window is not { } window + || ReferenceEquals(window, source)) + continue; + MoveWindowTo(sibling, placement); + } + } + finally + { + _synchronizingPlacement = false; + } + } + + private void MoveWindowTo(PanelEntry entry, PanelPlacement placement) + { + if (entry.Window is not { } window + || (window.Left == placement.Left && window.Top == placement.Top)) + return; + + window.MoveTo(placement.Left, placement.Top); + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + foreach (PanelEntry entry in _byPanel.Values) + if (entry.Window is { } window) + window.Moved -= OnWindowMoved; + } + + private readonly record struct PanelPlacement(float Left, float Top); + + private readonly record struct PanelEntry( + string WindowName, + bool RestorePrevious, + RetailWindowHandle? Window, + bool SharesMainPanelPlacement); } diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 8d43be79..0d3f7995 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -784,7 +784,7 @@ public sealed class RetailUiRuntime : IDisposable SpellbookWindowController = controller; UiElement root = layout.Root; - RetailWindowFrame.Mount( + RetailWindowHandle handle = RetailWindowFrame.Mount( Host.Root, root, _bindings.Assets.ResolveSprite, @@ -805,7 +805,10 @@ public sealed class RetailUiRuntime : IDisposable ContentClickThrough = false, Controller = controller, }); - _panelUi.Register(RetailPanelCatalog.Magic, WindowNames.Spellbook); + _panelUi.RegisterMainPanel( + RetailPanelCatalog.Magic, + WindowNames.Spellbook, + handle); Console.WriteLine("[M3] retail spellbook/component book from LayoutDesc 0x21000034."); } @@ -1134,7 +1137,10 @@ public sealed class RetailUiRuntime : IDisposable _bindings.Assets.ResolveSprite, (request, completed) => HandleCharacterRaise(provider, request, completed), () => CloseWindow(WindowNames.Character)); - RetailWindowFrame.Mount(Host.Root, layout.Root, _bindings.Assets.ResolveSprite, + RetailWindowHandle handle = RetailWindowFrame.Mount( + Host.Root, + layout.Root, + _bindings.Assets.ResolveSprite, new RetailWindowFrame.Options { WindowName = WindowNames.Character, @@ -1149,7 +1155,10 @@ public sealed class RetailUiRuntime : IDisposable ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom, ContentClickThrough = false, }); - _panelUi.Register(RetailPanelCatalog.Character, WindowNames.Character); + _panelUi.RegisterMainPanel( + RetailPanelCatalog.Character, + WindowNames.Character, + handle); Console.WriteLine("[D.2b-C] retail character window from LayoutDesc importer (0x2100002E)."); } @@ -1231,7 +1240,10 @@ public sealed class RetailUiRuntime : IDisposable Host.Root.Height - root.Top), ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom, }); - _panelUi.Register(RetailPanelCatalog.Inventory, WindowNames.Inventory); + _panelUi.RegisterMainPanel( + RetailPanelCatalog.Inventory, + WindowNames.Inventory, + handle); uint contents, sideBag, mainPack; IReadOnlyDictionary paperdollEmptySprites; @@ -1280,6 +1292,7 @@ public sealed class RetailUiRuntime : IDisposable _itemConfirmationController?.Dispose(); _gameplayConfirmationController?.Dispose(); DialogFactory?.Dispose(); + _panelUi.Dispose(); Host.Dispose(); } } diff --git a/tests/AcDream.App.Tests/UI/Layout/RetailPanelUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/RetailPanelUiControllerTests.cs index 7c000e7b..62114556 100644 --- a/tests/AcDream.App.Tests/UI/Layout/RetailPanelUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/RetailPanelUiControllerTests.cs @@ -1,3 +1,4 @@ +using AcDream.App.UI; using AcDream.App.UI.Layout; namespace AcDream.App.Tests.UI.Layout; @@ -74,6 +75,71 @@ public sealed class RetailPanelUiControllerTests Assert.Equal(2u, controller.ActivePanelId); } + [Fact] + public void MainPanels_ShareOneCanonicalPlacementAcrossMoveCloseAndSwitch() + { + var root = new UiRoot { Width = 1280f, Height = 720f }; + RetailWindowHandle inventory = Mount(root, WindowNames.Inventory, 40f, 60f); + RetailWindowHandle character = Mount(root, WindowNames.Character, 540f, 18f); + RetailWindowHandle spellbook = Mount(root, WindowNames.Spellbook, 18f, 18f); + using var controller = Create(root); + controller.RegisterMainPanel(7u, WindowNames.Inventory, inventory); + controller.RegisterMainPanel(11u, WindowNames.Character, character); + controller.RegisterMainPanel(13u, WindowNames.Spellbook, spellbook); + + int characterMoves = 0; + int spellbookMoves = 0; + character.Moved += _ => characterMoves++; + spellbook.Moved += _ => spellbookMoves++; + + controller.SetPanelVisibility(7u, visible: true); + inventory.MoveTo(312f, 147f); + + Assert.Equal((312f, 147f), (character.Left, character.Top)); + Assert.Equal((312f, 147f), (spellbook.Left, spellbook.Top)); + Assert.True(characterMoves > 0); + Assert.True(spellbookMoves > 0); + + controller.SetPanelVisibility(11u, visible: true); + Assert.False(inventory.IsVisible); + Assert.True(character.IsVisible); + Assert.Equal((312f, 147f), (character.Left, character.Top)); + + controller.SetPanelVisibility(11u, visible: false); + controller.SetPanelVisibility(13u, visible: true); + Assert.False(character.IsVisible); + Assert.True(spellbook.IsVisible); + Assert.Equal((312f, 147f), (spellbook.Left, spellbook.Top)); + } + + [Fact] + public void RestorePreviousEffect_DoesNotReplaceMainPanelPlacement() + { + var root = new UiRoot { Width = 1280f, Height = 720f }; + RetailWindowHandle inventory = Mount(root, WindowNames.Inventory, 40f, 60f); + RetailWindowHandle character = Mount(root, WindowNames.Character, 540f, 18f); + RetailWindowHandle effect = Mount(root, WindowNames.PositiveEffects, 900f, 30f); + using var controller = Create(root); + controller.RegisterMainPanel(7u, WindowNames.Inventory, inventory); + controller.RegisterMainPanel(11u, WindowNames.Character, character); + controller.Register(4u, WindowNames.PositiveEffects, restorePrevious: true); + + controller.SetPanelVisibility(7u, visible: true); + inventory.MoveTo(280f, 120f); + controller.SetPanelVisibility(4u, visible: true); + + Assert.Equal((900f, 30f), (effect.Left, effect.Top)); + Assert.False(inventory.IsVisible); + Assert.True(effect.IsVisible); + + controller.SetPanelVisibility(4u, visible: false); + Assert.True(inventory.IsVisible); + Assert.Equal((280f, 120f), (inventory.Left, inventory.Top)); + + controller.SetPanelVisibility(11u, visible: true); + Assert.Equal((280f, 120f), (character.Left, character.Top)); + } + private static RetailPanelUiController Create(Dictionary visible) => new( name => visible[name], @@ -87,4 +153,28 @@ public sealed class RetailPanelUiControllerTests visible[name] = false; return true; }); + + private static RetailPanelUiController Create(UiRoot root) + => new(root.IsWindowVisible, root.ShowWindow, root.HideWindow); + + private static RetailWindowHandle Mount( + UiRoot root, + string name, + float left, + float top) + { + var content = new UiPanel { Width = 300f, Height = 500f }; + return RetailWindowFrame.Mount( + root, + content, + _ => (0u, 0, 0), + new RetailWindowFrame.Options + { + WindowName = name, + Left = left, + Top = top, + Visible = false, + ConstrainDragToParent = true, + }); + } } diff --git a/tests/AcDream.App.Tests/UI/RetailWindowLayoutPersistenceTests.cs b/tests/AcDream.App.Tests/UI/RetailWindowLayoutPersistenceTests.cs index 7fd3aa8e..2554decc 100644 --- a/tests/AcDream.App.Tests/UI/RetailWindowLayoutPersistenceTests.cs +++ b/tests/AcDream.App.Tests/UI/RetailWindowLayoutPersistenceTests.cs @@ -1,4 +1,5 @@ using AcDream.App.UI; +using AcDream.App.UI.Layout; using AcDream.UI.Abstractions.Panels.Settings; namespace AcDream.App.Tests.UI; @@ -28,6 +29,47 @@ public sealed class RetailWindowLayoutPersistenceTests : IDisposable Assert.False(saved.Visible); } + [Fact] + public void MainPanelMove_PersistsCanonicalPositionForEveryPrimaryChild() + { + var store = new SettingsStore(PathName); + var root = new UiRoot { Width = 800, Height = 600 }; + RetailWindowHandle inventory = Mount(root, WindowNames.Inventory); + RetailWindowHandle character = Mount(root, WindowNames.Character); + inventory.Hide(); + character.Hide(); + + using var panels = new RetailPanelUiController( + root.IsWindowVisible, + root.ShowWindow, + root.HideWindow); + panels.RegisterMainPanel( + RetailPanelCatalog.Inventory, + WindowNames.Inventory, + inventory); + panels.RegisterMainPanel( + RetailPanelCatalog.Character, + WindowNames.Character, + character); + using var persistence = new RetailWindowLayoutPersistence( + root.WindowManager, + store, + () => "Alice", + () => (800, 600)); + + panels.SetPanelVisibility(RetailPanelCatalog.Inventory, visible: true); + inventory.MoveTo(244f, 133f); + + UiWindowLayout savedInventory = Assert.IsType( + store.LoadWindowLayout( + "Alice", "800x600", WindowNames.Inventory, default)); + UiWindowLayout savedCharacter = Assert.IsType( + store.LoadWindowLayout( + "Alice", "800x600", WindowNames.Character, default)); + Assert.Equal((244f, 133f), (savedInventory.X, savedInventory.Y)); + Assert.Equal((244f, 133f), (savedCharacter.X, savedCharacter.Y)); + } + [Fact] public void Restore_ClampsBoundsAndReappliesPanelStateAndVisibility() {