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 <codex@openai.com>
This commit is contained in:
Erik 2026-07-17 09:13:31 +02:00
parent 124e046976
commit 3f3cfdac30
7 changed files with 460 additions and 9 deletions

View file

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

View file

@ -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/` |

View file

@ -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<PanelChildInfo> 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.

View file

@ -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.
/// </summary>
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<string, bool> isVisible,
@ -34,21 +37,72 @@ public sealed class RetailPanelUiController
public uint? ActivePanelId => _activePanel;
/// <summary>
/// Registers one of retail <c>gmPanelUI</c>'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.
/// </summary>
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);
}
/// <param name="restorePrevious">
/// Effective DAT bool property <c>0x10000049</c>. Retail retains the previous
/// panel only when the newly shown panel has this property and the previous
/// panel does not.
/// </param>
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);
}

View file

@ -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<uint, uint> paperdollEmptySprites;
@ -1280,6 +1292,7 @@ public sealed class RetailUiRuntime : IDisposable
_itemConfirmationController?.Dispose();
_gameplayConfirmationController?.Dispose();
DialogFactory?.Dispose();
_panelUi.Dispose();
Host.Dispose();
}
}

View file

@ -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<string, bool> 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,
});
}
}

View file

@ -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<UiWindowLayout>(
store.LoadWindowLayout(
"Alice", "800x600", WindowNames.Inventory, default));
UiWindowLayout savedCharacter = Assert.IsType<UiWindowLayout>(
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()
{