diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 4132c333..3be79c62 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -54,7 +54,7 @@ accepted-divergence entries (#96, #49, #50). | 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-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. `RetailWindowManager`/typed handles centralize registry, raise, focus/capture cleanup, lifecycle events, and controller teardown; `RetailWindowFrame` is now the single production/Studio mount contract for both 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`, and radar `0x21000074`. | `src/AcDream.App/UI/RetailWindowManager.cs`; `src/AcDream.App/UI/Layout/RetailWindowFrame.cs`; `src/AcDream.App/UI/Layout/LayoutImporter.cs`; production mounts 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. | Unported persistence semantics can still make windows drift across restart/resolution changes; lifecycle edge cases remain constrained by conformance tests rather than a Keystone byte-port | Production LayoutDesc objects; `docs/research/2026-06-15-layoutdesc-format.md`; Keystone behavior notes in `docs/research/retail-ui/` | +| IA-15 | D.2b gameplay UI is our own `UiHost`/`UiRoot` retained tree, not a byte-port of Keystone. `RetailWindowManager`/typed handles centralize registry, raise, focus/capture cleanup, lifecycle events, controller teardown, and schema-v2 per-character/per-resolution layout persistence; `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`, and radar `0x21000074`. | `src/AcDream.App/UI/RetailWindowManager.cs`; `src/AcDream.App/UI/RetailWindowLayoutPersistence.cs`; `src/AcDream.App/UI/Layout/RetailWindowFrame.cs`; `src/AcDream.App/UI/Layout/LayoutImporter.cs`; production mounts 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 is behaviorally reconstructed from `saveui/loadui` semantics rather than Keystone internals; lifecycle edge cases remain constrained by conformance tests instead of a byte-port | Production LayoutDesc objects; `docs/research/2026-06-15-layoutdesc-format.md`; Keystone behavior notes in `docs/research/retail-ui/` | | IA-17 | Toolbar chrome is toolkit-supplied through the central `RetailWindowFrame` mount (`UiCollapsibleFrame` 8-piece bevel) because LayoutDesc `0x21000016` carries no baked frame. It also supports a toolkit-defined collapse-to-one-row (bottom-edge resize snapping between a row-1-only and a two-row height, row-2 visibility tied to the stop) — retail's real collapse is keystone.dll (no decomp) and the DAT stacks both rows always. | `src/AcDream.App/UI/Layout/RetailWindowFrame.cs`; `src/AcDream.App/UI/UiCollapsibleFrame.cs`; toolbar policy in `GameWindow.cs`; spec: `docs/superpowers/specs/2026-06-20-d2b-toolbar-collapse-design.md` | The central mount now owns wrapper geometry/registration uniformly; border-over-content prevents the row-2 right cap from poking through | The collapse stops remain a toolkit reconstruction rather than a byte-port of Keystone behavior | gmToolbarUI WM chrome (keystone.dll, no PDB); no bevel ids in LayoutDesc 0x21000016 (toolbar dump) | | IA-18 | Effect overlay tile (enum 0x10000005) is a `ReplaceColor` SURFACE SOURCE — pure-white pixels in the composited drag icon are replaced PER-PIXEL with the same (x,y) pixel of the effect tile (the SURFACE overload `SurfaceWindow::ReplaceColor` 0x004415b0), preserving the tile's texture/gradient; the tile itself is NOT blitted as an additional layer. This IS faithful retail behavior. **Anti-regression: do NOT re-implement this as a blit layer NOR as a flat-color replace (it is a per-pixel surface copy).** | `src/AcDream.App/UI/IconComposer.cs` (`ReplaceWhiteFromSurface`) | Faithful port of `IconData::RenderIcons` @407614 → the SURFACE overload `ReplaceColor` 0x004415b0 (`dst[x,y]=src[x,y]` where `dst==white`); confirmed via clean Ghidra decompile + named decomp + visual (the Energy Crystal's blue is a gradient, 2026-06-17). | A blit-layer or flat-color re-implementation would show the wrong effect look (no gradient) — the visual-verification regression that retired the mean-color approximation | `IconData::RenderIcons` acclient_2013_pseudo_c.txt:407524; `ReplaceColor` SURFACE overload 0x004415b0:71656; `docs/research/2026-06-17-stateful-icon-RESOLVED.md` | @@ -203,7 +203,7 @@ AP-94..AP-110 for the confirmed retail-UI completion gaps. | AP-95 | `UiButton` dispatches Click only; it lacks retail hover, pressed, released-outside, disabled, selected/toggle, and missing-state fallback transitions | `src/AcDream.App/UI/UiButton.cs` | Controllers manually set a few semantic states; generic pointer visuals remain incomplete | Buttons look inert or overwrite custom states, and disabled/toggle behavior can fire incorrectly | `UIElement_Button::UpdateState_ @ 0x00471CF0`; mouse handlers `0x00471FF0..0x004721F0` | | AP-96 | Layout/property import loses raw edge-mode semantics (especially 3/4) and treats default scalar values as absent, so explicit `false`/`0` cannot override inheritance | `src/AcDream.App/UI/Layout/ElementReader.cs`; `LayoutImporter.cs` | Existing shipped layouts are hand-gated; Wave 1 adds presence-aware properties and the exact solver | Non-reference resizing recenters/stretches incorrectly; derived DAT properties silently inherit the wrong base value | `UIElement::UpdateForParentSizeChange @ 0x00462640`; production LayoutDesc inheritance | | ~~AP-97~~ | **RETIRED 2026-07-10 (Wave 1 retained-widget foundation)** — the prior generic Device-timer premise was a decomp misread. Named retail polls the hovered element's tooltip deadline, then broadcasts global UI time message `3`; it does not expose the assumed arbitrary timer queue. `UiRoot.Tick` now preserves that order and subtree removal clears input/time ownership before another pulse. | `src/AcDream.App/UI/UiRoot.cs`; `IUiGlobalTimeListener.cs` | — | — | `UIElementManager::UseTime` and hover/focus paths, pinned in `docs/research/2026-07-10-retained-widget-foundations-pseudocode.md` | -| AP-98 | **PARTIAL 2026-07-10 (Wave 2.2)** — typed handles/manager own lifecycle and every production/Studio window uses `RetailWindowFrame`. Chat maximize now ports the half-parent growth/restore algorithm, resizes the outer frame, uses DAT 100–360 height limits, and drives imported button states. Complete layout persistence is still absent. | `src/AcDream.App/UI/RetailWindowManager.cs`; `RetailWindowHandle.cs`; `Layout/RetailWindowFrame.cs`; `ChatWindowController.cs` | Lifecycle, mounts, and chat outer geometry now have one tested owner; Wave 2.3 adds persistence | Layouts still reset/drift across restart and resolution/character changes | `gmMainChatUI::HandleMaximizeButton @ 0x004CCE50`; retail saveui/loadui behavior | +| ~~AP-98~~ | **RETIRED 2026-07-10 (Wave 2.3)** — typed handles/manager own lifecycle; every production/Studio window uses `RetailWindowFrame`; schema-v2 `UiWindowLayout` persists outer bounds, visibility, toolbar collapse, and chat maximize by character/resolution with legacy-radar migration, nearest-resolution fallback, clamping, and a pre-login `default` write guard. Chat maximize uses DAT 100–360 limits and preserves its lower edge when growing upward. | `src/AcDream.App/UI/RetailWindowManager.cs`; `RetailWindowLayoutPersistence.cs`; `RetailWindowHandle.cs`; `Layout/RetailWindowFrame.cs`; `ChatWindowController.cs`; `SettingsStore.cs` | — | — | `gmMainChatUI::HandleMaximizeButton @ 0x004CCE50`; retail `saveui/loadui` behavior | | AP-99 | Shared item activation implements only a subset of `ItemHolder::UseObject`/placement legality and confirmation policy | `src/AcDream.App/UI/ItemInteractionController.cs` | Current PvE use/equip/container flows cover the M2 loop; Wave 3 ports the complete pure decision matrix | Vendor/trade/busy/confirmation/target edge cases send the wrong action or permit an illegal use | `ItemHolder::DetermineUseResult @ 0x00588460`; `UseObject @ 0x00588A80`; `AttemptPlaceIn3D @ 0x00588600` | | AP-100 | Cursor semantics cover only a reachable subset of retail states, and widget cursor media can outrank authoritative global target mode | `src/AcDream.App/UI/CursorFeedbackController.cs`; `src/AcDream.App/Rendering/RetailCursorManager.cs` | Target-use art works for current flows; Wave 3 ports the full table and precedence | Hovering UI can replace the use/examine/busy/combat cursor; unsupported branches fall back to OS art | `ClientUISystem::UpdateCursorState @ 0x00564630` | | AP-101 | Toolbar quick-slot actions and most panel/Use/Examine/combat controls are not fully dispatched; selected-object mana and stack entry/slider are absent | `src/AcDream.App/UI/Layout/ToolbarController.cs`; `GameWindow.OnInputAction` | Item click/use, stance indicators, health/name, and some toggles are present | Bound retail keys/buttons do nothing and item/stack selections show incomplete state | `gmToolbarUI @ 0x004BD0C0..0x004BF380`; LayoutDesc `0x21000016` | diff --git a/docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md b/docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md index c15a29a3..6563f2d3 100644 --- a/docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md +++ b/docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md @@ -259,15 +259,21 @@ scrollbar buttons, track/thumb dragging, wheel scrolling, and bottom-pin behavio ### 2.3 Persist the complete UI layout -- [ ] Replace radar-only persistence with `UiWindowLayout` containing position, +- [x] Replace radar-only persistence with `UiWindowLayout` containing position, size, visibility, collapsed/maximized state, and any retail-persisted panel state. -- [ ] Key layouts by character and resolution, matching retail `saveui/loadui` +- [x] Key layouts by character and resolution, matching retail `saveui/loadui` semantics; add migration from existing radar entries. -- [ ] Restore only after the character context and screen size are known; clamp +- [x] Restore only after the character context and screen size are known; clamp safely on resolution changes. -- [ ] Save move/resize on interaction completion, visibility on transition, and +- [x] Save move/resize on interaction completion, visibility on transition, and never let the temporary `default` login key overwrite character-specific state. -- [ ] Apply global LockUI through the window manager to every window. +- [x] Apply global LockUI through the window manager to every window. + +**Acceptance (2026-07-10):** schema-v2 per-character/resolution restore passed a +live Debug restart with every retained window persisted. The gate also caught and +closed hidden-inventory baseline capture (expanded main-pack rows) and chat's +clamped maximize jump (lower edge now remains fixed). Full suite green: 4,545 +passed, 5 skipped. ### 2.4 Enforce controller teardown diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 5665a995..f2940b29 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -764,7 +764,6 @@ public sealed class GameWindow : IDisposable // Core + the retained controller; GameWindow supplies live state only. private AcDream.App.UI.Layout.RadarController? _radarController; private AcDream.App.UI.Layout.RadarSnapshotProvider? _radarSnapshotProvider; - private AcDream.App.UI.UiRadar? _radarRoot; // Phase D.2b-B — inventory controller (backpack grid + pack-selector + burden meter). private AcDream.App.UI.Layout.InventoryController? _inventoryController; // Phase D.2b Sub-phase C — paperdoll equip-slot controller (wield drag handler). @@ -2036,7 +2035,6 @@ public sealed class GameWindow : IDisposable sendRaiseSkill: (statId, cost) => _liveSession?.SendRaiseSkill(statId, cost), sendTrainSkill: (statId, credits) => _liveSession?.SendTrainSkill(statId, credits)); _uiHost.Root.DragReleasedOutsideUi += OnUiDragReleasedOutside; - _uiHost.Root.WindowMoved += OnRetailWindowMoved; // Feed Silk input to the UiRoot tree so windows drag / close / select. // UiRoot consumes UI events; the game InputDispatcher (subscribed to the @@ -2130,7 +2128,6 @@ public sealed class GameWindow : IDisposable ResolveChrome, vitalsDatFont, ResolveDatFont); if (radarLayout is not null && radarLayout.Root is AcDream.App.UI.UiRadar radarRoot) { - _radarRoot = radarRoot; _radarSnapshotProvider = new AcDream.App.UI.Layout.RadarSnapshotProvider( Objects, _entitiesByServerGuid, @@ -2162,7 +2159,6 @@ public sealed class GameWindow : IDisposable ConstrainDragToParent = true, ContentClickThrough = false, }); - ApplySavedRadarPosition(); Console.WriteLine("[D.6] retail radar/compass from LayoutDesc 0x21000074."); } else @@ -2223,6 +2219,7 @@ public sealed class GameWindow : IDisposable ResizeX = true, ResizeY = true, Opacity = 0.75f, + StateController = chatController, }); chatController.AttachWindow(chatHandle); // The LayoutDesc root is 800px wide because it shares the display @@ -2529,27 +2526,8 @@ public sealed class GameWindow : IDisposable }); var inventoryFrame = (AcDream.App.UI.UiNineSlicePanel)inventoryHandle.OuterFrame; - // Stretch the contents region with the window: the gm3DItemsUI sub-window + its grid - // + scrollbar + the full-window backdrop grow vertically (Left|Top|Bottom); the - // paperdoll + side-bag column stay pinned at the top (Left|Top). The grid's ViewHeight - // grows → more rows + the scrollbar thumb ratio (view/content) reflects it. - void StretchV(uint id) - { - if (invLayout!.FindElement(id) is { } el) - el.Anchors = AcDream.App.UI.AnchorEdges.Left | AcDream.App.UI.AnchorEdges.Top - | AcDream.App.UI.AnchorEdges.Bottom; - } - void PinTopLeft(uint id) - { - if (invLayout!.FindElement(id) is { } el) - el.Anchors = AcDream.App.UI.AnchorEdges.Left | AcDream.App.UI.AnchorEdges.Top; - } - StretchV(0x100001D0u); // full-window backdrop - StretchV(0x100001CFu); // gm3DItemsUI sub-window (contents-grid container) - StretchV(0x100001C6u); // contents grid (UiItemList) - StretchV(0x100001C7u); // contents scrollbar - PinTopLeft(0x100001CDu); // paperdoll (fixed at top) - PinTopLeft(0x100001CEu); // side-bag column (fixed at top) + // InventoryController owns descendant resize/reflow policy and captures + // its hidden-window baselines when it binds below. // Phase D.2b-B — populate the inventory from ClientObjectTable: the // "Contents of Backpack" grid + the pack-selector strip + the burden meter + @@ -2616,6 +2594,15 @@ public sealed class GameWindow : IDisposable toggleCharacter: () => ToggleRetailWindow(AcDream.App.UI.WindowNames.Character)); SyncToolbarWindowButtons(); + if (_settingsStore is not null) + { + _windowLayoutPersistence = new AcDream.App.UI.RetailWindowLayoutPersistence( + _uiHost.WindowManager, + _settingsStore, + characterKey: () => _activeToonKey, + screenSize: () => (_window!.Size.X, _window.Size.Y)); + } + if (_options.UiProbeEnabled) { void ProbeLog(string message) => Console.WriteLine("[UI-PROBE] " + message); @@ -2910,7 +2897,8 @@ public sealed class GameWindow : IDisposable _liveSession.EnterWorld(user, characterIndex: 0); _activeToonKey = chosen.Name; - ApplySavedRadarPosition(); + _windowLayoutPersistence?.RestoreAll(); + SyncToolbarWindowButtons(); if (_settingsStore is not null && _settingsVm is not null) { var toonBag = _settingsStore.LoadCharacter(_activeToonKey); @@ -11799,6 +11787,7 @@ public sealed class GameWindow : IDisposable // "default" and gets swapped to the actual character name on the // first EnterWorld. private AcDream.UI.Abstractions.Panels.Settings.SettingsStore? _settingsStore; + private AcDream.App.UI.RetailWindowLayoutPersistence? _windowLayoutPersistence; private string _activeToonKey = "default"; private bool ToggleRetailWindow(string name) @@ -11842,41 +11831,6 @@ public sealed class GameWindow : IDisposable } } - private void ApplySavedRadarPosition() - { - if (_radarRoot is null || _settingsStore is null || _window is null) - return; - - var saved = _settingsStore.LoadWindowPosition( - _activeToonKey, AcDream.App.UI.WindowNames.Radar); - if (saved is not { } position) - return; - - _radarRoot.Left = System.Math.Clamp( - position.X, 0f, System.Math.Max(0f, _window.Size.X - _radarRoot.Width)); - _radarRoot.Top = System.Math.Clamp( - position.Y, 0f, System.Math.Max(0f, _window.Size.Y - _radarRoot.Height)); - } - - private void OnRetailWindowMoved(string name, AcDream.App.UI.UiElement window) - { - if (name != AcDream.App.UI.WindowNames.Radar || _settingsStore is null) - return; - - try - { - _settingsStore.SaveWindowPosition( - _activeToonKey, - name, - new AcDream.UI.Abstractions.Panels.Settings.UiWindowPosition( - window.Left, window.Top)); - } - catch (Exception ex) - { - Console.WriteLine($"settings: radar position save failed: {ex.Message}"); - } - } - private void UpdateRetailCursorFeedback() { if (_uiHost is null || _cursorFeedbackController is null || _retailCursorManager is null || _input is null) @@ -14097,6 +14051,8 @@ public sealed class GameWindow : IDisposable _paperdollViewportRenderer?.Dispose(); // Slice 2: the doll's off-screen FBO + textures _particleRenderer?.Dispose(); _debugLines?.Dispose(); + _windowLayoutPersistence?.Dispose(); + _windowLayoutPersistence = null; _uiHost?.Dispose(); _textRenderer?.Dispose(); _debugFont?.Dispose(); diff --git a/src/AcDream.App/Studio/MockupDesktop.cs b/src/AcDream.App/Studio/MockupDesktop.cs index ff6c2b06..233e6385 100644 --- a/src/AcDream.App/Studio/MockupDesktop.cs +++ b/src/AcDream.App/Studio/MockupDesktop.cs @@ -254,6 +254,7 @@ internal static class MockupDesktop ResizeX = true, ResizeY = true, Opacity = 0.75f, + StateController = controller, }); controller.AttachWindow(handle); diff --git a/src/AcDream.App/UI/IRetainedWindowStateController.cs b/src/AcDream.App/UI/IRetainedWindowStateController.cs new file mode 100644 index 00000000..cdb129bc --- /dev/null +++ b/src/AcDream.App/UI/IRetainedWindowStateController.cs @@ -0,0 +1,18 @@ +namespace AcDream.App.UI; + +/// Panel state that is not completely described by outer-frame bounds. +public readonly record struct RetainedWindowState( + bool Collapsed = false, + bool Maximized = false, + float? PersistedTop = null, + float? PersistedHeight = null); + +/// +/// Optional state seam used by retained-window persistence. Bounds are restored +/// first; the controller then reapplies collapsed/maximized presentation. +/// +public interface IRetainedWindowStateController +{ + RetainedWindowState CaptureWindowState(); + void RestoreWindowState(RetainedWindowState state); +} diff --git a/src/AcDream.App/UI/Layout/ChatWindowController.cs b/src/AcDream.App/UI/Layout/ChatWindowController.cs index dabe602d..a944988c 100644 --- a/src/AcDream.App/UI/Layout/ChatWindowController.cs +++ b/src/AcDream.App/UI/Layout/ChatWindowController.cs @@ -24,7 +24,7 @@ namespace AcDream.App.UI.Layout; /// and bound in place. /// /// -public sealed class ChatWindowController +public sealed class ChatWindowController : IRetainedWindowStateController { public const uint LayoutId = 0x21000006u; @@ -364,13 +364,16 @@ public sealed class ChatWindowController _normalHeight = frame.Height; float expansion = parentHeight / 2f; - float targetTop = frame.Top; - float targetHeight = frame.Height + expansion; - if (frame.Top + targetHeight > parentHeight || frame.Top >= parentHeight / 2f) - targetTop = MathF.Max(0f, frame.Top - expansion); - + float targetHeight = Math.Clamp( + MathF.Min(frame.Height + expansion, parentHeight), + frame.MinHeight, + frame.MaxHeight); + bool growUp = frame.Top + targetHeight > parentHeight + || frame.Top >= parentHeight / 2f; + float targetTop = growUp + ? MathF.Max(0f, frame.Top - (targetHeight - frame.Height)) + : frame.Top; targetHeight = MathF.Min(targetHeight, parentHeight - targetTop); - targetHeight = Math.Clamp(targetHeight, frame.MinHeight, frame.MaxHeight); _maximized = true; _maxMinButton?.TrySetRetailState(RetailUiStateIds.Maximized); @@ -378,6 +381,21 @@ public sealed class ChatWindowController handle.MoveTo(frame.Left, targetTop); } + public RetainedWindowState CaptureWindowState() + => new( + Maximized: _maximized, + PersistedTop: _maximized ? _normalTop : null, + PersistedHeight: _maximized ? _normalHeight : null); + + public void RestoreWindowState(RetainedWindowState state) + { + if (state.Maximized != _maximized) + ToggleMaximize(); + else + _maxMinButton?.TrySetRetailState( + _maximized ? RetailUiStateIds.Maximized : RetailUiStateIds.Minimized); + } + private static ElementInfo? FindInfo(ElementInfo node, uint id) { if (node.Id == id) return node; diff --git a/src/AcDream.App/UI/Layout/InventoryController.cs b/src/AcDream.App/UI/Layout/InventoryController.cs index a61a56f4..b7ba9379 100644 --- a/src/AcDream.App/UI/Layout/InventoryController.cs +++ b/src/AcDream.App/UI/Layout/InventoryController.cs @@ -24,6 +24,10 @@ public sealed class InventoryController : IItemListDragHandler public const uint ContentsCaptionId = 0x100001C5u; // "Contents of Backpack" public const uint TitleTextId = 0x100001D3u; // "Inventory of " public const uint ContentsScrollbarId = 0x100001C7u; // gm3DItemsUI gutter scrollbar (right of the grid) + private const uint BackdropId = 0x100001D0u; + private const uint ContentsWindowId = 0x100001CFu; + private const uint PaperdollWindowId = 0x100001CDu; + private const uint BackpackWindowId = 0x100001CEu; // 3D-items grid: 192x96 → 6 cols x 3 rows of the 32x32 UIItem cell (template 0x21000037). private const int ContentsColumns = 6; @@ -92,6 +96,8 @@ public sealed class InventoryController : IItemListDragHandler _containerList = layout.FindElement(ContainerListId) as UiItemList; _topContainer = layout.FindElement(TopContainerId) as UiItemList; + ConfigureResizeLayout(layout); + if (_contentsGrid is not null) { _contentsGrid.Columns = ContentsColumns; @@ -153,6 +159,24 @@ public sealed class InventoryController : IItemListDragHandler Populate(); } + internal static void ConfigureResizeLayout(ImportedLayout layout) + { + static void Set(ImportedLayout imported, uint id, AnchorEdges anchors) + { + if (imported.FindElement(id) is not { } element) return; + element.Anchors = anchors; + element.CaptureCurrentAnchorBaseline(); + } + + AnchorEdges stretch = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom; + Set(layout, BackdropId, stretch); + Set(layout, ContentsWindowId, stretch); + Set(layout, ContentsGridId, stretch); + Set(layout, ContentsScrollbarId, stretch); + Set(layout, PaperdollWindowId, AnchorEdges.Left | AnchorEdges.Top); + Set(layout, BackpackWindowId, AnchorEdges.Left | AnchorEdges.Top); + } + public static InventoryController Bind( ImportedLayout layout, ClientObjectTable objects, diff --git a/src/AcDream.App/UI/Layout/RetailWindowFrame.cs b/src/AcDream.App/UI/Layout/RetailWindowFrame.cs index 1240b4ae..b9d38594 100644 --- a/src/AcDream.App/UI/Layout/RetailWindowFrame.cs +++ b/src/AcDream.App/UI/Layout/RetailWindowFrame.cs @@ -66,6 +66,7 @@ public static class RetailWindowFrame AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom; public bool? ContentClickThrough { get; init; } public IRetainedPanelController? Controller { get; init; } + public IRetainedWindowStateController? StateController { get; init; } } public static RetailWindowHandle Mount( @@ -159,7 +160,8 @@ public static class RetailWindowFrame options.WindowName, outerFrame, content, - options.Controller); + options.Controller, + options.StateController); } private static float ResolveConstraint( diff --git a/src/AcDream.App/UI/README.md b/src/AcDream.App/UI/README.md index 839577cf..45a79081 100644 --- a/src/AcDream.App/UI/README.md +++ b/src/AcDream.App/UI/README.md @@ -38,6 +38,9 @@ synthesis + six deep-dive docs under geometry events, lock propagation, and controller teardown. - `IRetainedPanelController.cs` — disposable lifecycle contract for panel-specific show/hide and descendant-focus behavior. +- `RetailWindowLayoutPersistence.cs` / `IRetainedWindowStateController.cs` — + per-character/per-resolution bounds, visibility, collapse/maximize, legacy + migration, and safe resolution-clamped restore. - `Layout/RetailWindowFrame.cs` — the single production/Studio mount contract for imported-chrome and shared-wrapper windows. - `UiHost.cs` — one-shot wrapper: owns the `UiRoot`, a `TextRenderer`, diff --git a/src/AcDream.App/UI/RetailWindowHandle.cs b/src/AcDream.App/UI/RetailWindowHandle.cs index da96ffcd..c200f30c 100644 --- a/src/AcDream.App/UI/RetailWindowHandle.cs +++ b/src/AcDream.App/UI/RetailWindowHandle.cs @@ -19,13 +19,15 @@ public sealed class RetailWindowHandle string name, UiElement outerFrame, UiElement contentRoot, - IRetainedPanelController? controller) + IRetainedPanelController? controller, + IRetainedWindowStateController? stateController) { _owner = owner; Name = name; OuterFrame = outerFrame; ContentRoot = contentRoot; Controller = controller; + StateController = stateController; _notifiedVisible = outerFrame.Visible; } @@ -33,6 +35,7 @@ public sealed class RetailWindowHandle public UiElement OuterFrame { get; } public UiElement ContentRoot { get; } public IRetainedPanelController? Controller { get; } + public IRetainedWindowStateController? StateController { get; } public bool IsRegistered => !_disposed; public bool IsVisible => OuterFrame.Visible; public bool IsLocked => _owner.IsLocked; diff --git a/src/AcDream.App/UI/RetailWindowLayoutPersistence.cs b/src/AcDream.App/UI/RetailWindowLayoutPersistence.cs new file mode 100644 index 00000000..e4b8eb28 --- /dev/null +++ b/src/AcDream.App/UI/RetailWindowLayoutPersistence.cs @@ -0,0 +1,176 @@ +using System; +using System.Collections.Generic; +using AcDream.UI.Abstractions.Panels.Settings; + +namespace AcDream.App.UI; + +/// +/// Bridges typed retained-window lifecycle events to per-character, +/// per-resolution settings. It deliberately ignores the temporary pre-login +/// default character key so startup layout cannot overwrite a real +/// character's state. +/// +public sealed class RetailWindowLayoutPersistence : IDisposable +{ + private readonly RetailWindowManager _manager; + private readonly SettingsStore _store; + private readonly Func _characterKey; + private readonly Func<(int Width, int Height)> _screenSize; + private readonly List _attached = new(); + private bool _restoring; + private bool _disposed; + + public RetailWindowLayoutPersistence( + RetailWindowManager manager, + SettingsStore store, + Func characterKey, + Func<(int Width, int Height)> screenSize) + { + _manager = manager ?? throw new ArgumentNullException(nameof(manager)); + _store = store ?? throw new ArgumentNullException(nameof(store)); + _characterKey = characterKey ?? throw new ArgumentNullException(nameof(characterKey)); + _screenSize = screenSize ?? throw new ArgumentNullException(nameof(screenSize)); + + foreach (RetailWindowHandle handle in manager.Windows) + Attach(handle); + } + + /// Restore all registered windows after character and screen are known. + public void RestoreAll() + { + ObjectDisposedException.ThrowIf(_disposed, this); + string character = _characterKey(); + if (!CanPersist(character)) return; + var screen = ValidScreenSize(); + string resolution = ResolutionKey(screen); + + _restoring = true; + try + { + foreach (RetailWindowHandle handle in _attached) + { + UiWindowLayout fallback = Capture(handle); + UiWindowLayout? saved = _store.LoadWindowLayout( + character, resolution, handle.Name, fallback); + if (saved is not { } layout) continue; + + Apply(handle, layout, screen); + // Lazily migrate legacy position-only entries into the complete schema. + _store.SaveWindowLayout(character, resolution, handle.Name, Capture(handle)); + } + } + finally + { + _restoring = false; + } + } + + private void Attach(RetailWindowHandle handle) + { + _attached.Add(handle); + handle.Moved += OnChanged; + handle.Resized += OnChanged; + handle.Shown += OnChanged; + handle.Hidden += OnChanged; + } + + private void OnChanged(RetailWindowHandle handle) + { + if (_restoring || _disposed) return; + string character = _characterKey(); + if (!CanPersist(character)) return; + + try + { + var screen = ValidScreenSize(); + _store.SaveWindowLayout( + character, + ResolutionKey(screen), + handle.Name, + Capture(handle)); + } + catch (Exception ex) + { + Console.WriteLine($"settings: window layout save failed [{handle.Name}]: {ex.Message}"); + } + } + + private static UiWindowLayout Capture(RetailWindowHandle handle) + { + RetainedWindowState state = handle.StateController?.CaptureWindowState() ?? default; + return new UiWindowLayout( + handle.Left, + state.PersistedTop ?? handle.Top, + handle.Width, + state.PersistedHeight ?? handle.Height, + handle.IsVisible, + state.Collapsed, + state.Maximized); + } + + private static void Apply( + RetailWindowHandle handle, + UiWindowLayout layout, + (int Width, int Height) screen) + { + UiElement frame = handle.OuterFrame; + float width = ClampDimension(layout.Width, frame.Width, frame.MinWidth, frame.MaxWidth, screen.Width); + float height = ClampDimension(layout.Height, frame.Height, frame.MinHeight, frame.MaxHeight, screen.Height); + handle.ResizeTo(width, height); + + float maxX = MathF.Max(0f, screen.Width - handle.Width); + float maxY = MathF.Max(0f, screen.Height - handle.Height); + float x = Math.Clamp(FiniteOr(layout.X, handle.Left), 0f, maxX); + float y = Math.Clamp(FiniteOr(layout.Y, handle.Top), 0f, maxY); + handle.MoveTo(x, y); + + handle.StateController?.RestoreWindowState(new RetainedWindowState( + Collapsed: layout.Collapsed, + Maximized: layout.Maximized)); + + if (layout.Visible) handle.Show(); + else handle.Hide(); + } + + private (int Width, int Height) ValidScreenSize() + { + var screen = _screenSize(); + return (Math.Max(1, screen.Width), Math.Max(1, screen.Height)); + } + + private static float ClampDimension( + float saved, + float current, + float minimum, + float maximum, + int screenExtent) + { + float value = FiniteOr(saved, current); + float upper = MathF.Max(minimum, MathF.Min(maximum, screenExtent)); + return Math.Clamp(value, minimum, upper); + } + + private static float FiniteOr(float value, float fallback) + => float.IsFinite(value) ? value : fallback; + + private static string ResolutionKey((int Width, int Height) screen) + => $"{screen.Width}x{screen.Height}"; + + private static bool CanPersist(string key) + => !string.IsNullOrWhiteSpace(key) + && !string.Equals(key, "default", StringComparison.OrdinalIgnoreCase); + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + foreach (RetailWindowHandle handle in _attached) + { + handle.Moved -= OnChanged; + handle.Resized -= OnChanged; + handle.Shown -= OnChanged; + handle.Hidden -= OnChanged; + } + _attached.Clear(); + } +} diff --git a/src/AcDream.App/UI/RetailWindowManager.cs b/src/AcDream.App/UI/RetailWindowManager.cs index 3be2aba0..26dd05d5 100644 --- a/src/AcDream.App/UI/RetailWindowManager.cs +++ b/src/AcDream.App/UI/RetailWindowManager.cs @@ -36,7 +36,8 @@ public sealed class RetailWindowManager : IDisposable string name, UiElement outerFrame, UiElement? contentRoot = null, - IRetainedPanelController? controller = null) + IRetainedPanelController? controller = null, + IRetainedWindowStateController? stateController = null) { ObjectDisposedException.ThrowIf(_disposed, this); ArgumentException.ThrowIfNullOrWhiteSpace(name); @@ -46,11 +47,15 @@ public sealed class RetailWindowManager : IDisposable throw new InvalidOperationException( $"Retained window '{name}' must be mounted as a direct UiRoot child before registration."); + IRetainedWindowStateController? resolvedStateController = + stateController ?? outerFrame as IRetainedWindowStateController; + if (_byName.TryGetValue(name, out var sameName)) { if (ReferenceEquals(sameName.OuterFrame, outerFrame) && ReferenceEquals(sameName.ContentRoot, contentRoot ?? outerFrame) - && ReferenceEquals(sameName.Controller, controller)) + && ReferenceEquals(sameName.Controller, controller) + && ReferenceEquals(sameName.StateController, resolvedStateController)) return sameName; Unregister(name); @@ -64,7 +69,8 @@ public sealed class RetailWindowManager : IDisposable name, outerFrame, contentRoot ?? outerFrame, - controller); + controller, + resolvedStateController); _byName.Add(name, handle); _byFrame.Add(outerFrame, handle); handle.NotifyInitialState(); diff --git a/src/AcDream.App/UI/UiCollapsibleFrame.cs b/src/AcDream.App/UI/UiCollapsibleFrame.cs index 7f1fc340..97899243 100644 --- a/src/AcDream.App/UI/UiCollapsibleFrame.cs +++ b/src/AcDream.App/UI/UiCollapsibleFrame.cs @@ -10,7 +10,7 @@ namespace AcDream.App.UI; /// the dragged height to the nearer stop so the frame always rests collapsed or expanded — never a /// half-row. Toolkit UX (keystone.dll has no decomp; the dat stacks both rows always) — see IA-17. /// -public sealed class UiCollapsibleFrame : UiNineSlicePanel +public sealed class UiCollapsibleFrame : UiNineSlicePanel, IRetainedWindowStateController { public UiCollapsibleFrame(Func resolve) : base(resolve) { } @@ -33,4 +33,15 @@ public sealed class UiCollapsibleFrame : UiNineSlicePanel /// Test hook — OnTick is protected. Drives one snap+visibility reconcile. internal void TickForTest(double dt) => OnTick(dt); + + public RetainedWindowState CaptureWindowState() + => new(Collapsed: !IsExpanded); + + public void RestoreWindowState(RetainedWindowState state) + { + if (ExpandedHeight <= CollapsedHeight) return; + Height = state.Collapsed ? CollapsedHeight : ExpandedHeight; + for (int i = 0; i < SecondRow.Count; i++) + SecondRow[i].Visible = !state.Collapsed; + } } diff --git a/src/AcDream.App/UI/UiElement.cs b/src/AcDream.App/UI/UiElement.cs index c0c9dc43..4c0ec2e3 100644 --- a/src/AcDream.App/UI/UiElement.cs +++ b/src/AcDream.App/UI/UiElement.cs @@ -526,6 +526,18 @@ public abstract class UiElement (int)Parent.Height)); } + /// + /// Capture the current mounted rect as the baseline for a compatibility + /// anchor policy immediately. Hidden trees do not receive a layout traversal, + /// so lazy capture after a persistence resize would use the wrong parent size. + /// + internal void CaptureCurrentAnchorBaseline() + { + if (Parent is null || Anchors == AnchorEdges.None) return; + _anchorCaptured = false; + ApplyAnchor(Parent.Width, Parent.Height); + } + /// /// Rebase each immediate child after a host deliberately changes this element's /// design extent. This is used when a production DAT root contains auxiliary diff --git a/src/AcDream.App/UI/UiHost.cs b/src/AcDream.App/UI/UiHost.cs index 9f6bb3a9..409c4613 100644 --- a/src/AcDream.App/UI/UiHost.cs +++ b/src/AcDream.App/UI/UiHost.cs @@ -111,8 +111,9 @@ public sealed class UiHost : System.IDisposable string name, UiElement window, UiElement? contentRoot = null, - IRetainedPanelController? controller = null) - => Root.RegisterWindow(name, window, contentRoot, controller); + IRetainedPanelController? controller = null, + IRetainedWindowStateController? stateController = null) + => Root.RegisterWindow(name, window, contentRoot, controller, stateController); public bool UnregisterWindow(string name) => Root.UnregisterWindow(name); diff --git a/src/AcDream.App/UI/UiRoot.cs b/src/AcDream.App/UI/UiRoot.cs index 3562e5d7..aa670921 100644 --- a/src/AcDream.App/UI/UiRoot.cs +++ b/src/AcDream.App/UI/UiRoot.cs @@ -704,8 +704,9 @@ public sealed class UiRoot : UiElement string name, UiElement window, UiElement? contentRoot = null, - IRetainedPanelController? controller = null) - => WindowManager.Register(name, window, contentRoot, controller); + IRetainedPanelController? controller = null, + IRetainedWindowStateController? stateController = null) + => WindowManager.Register(name, window, contentRoot, controller, stateController); public bool UnregisterWindow(string name) => WindowManager.Unregister(name); diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs index 99cf3132..ee38cbf6 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs @@ -18,11 +18,12 @@ public readonly record struct UiWindowPosition(float X, float Y); /// path. /// /// -/// Schema (current version 1): +/// Schema (current version 2): /// /// { -/// "version": 1, +/// "version": 2, /// "display": { "resolution": "1920x1080", "fullscreen": false, ... } +/// "windowLayouts": { "Character": { "1920x1080": { "chat": { ... } } } } /// } /// /// Unknown top-level keys are preserved on save so future tab additions @@ -32,7 +33,7 @@ public readonly record struct UiWindowPosition(float X, float Y); /// public sealed class SettingsStore { - private const int CurrentSchemaVersion = 1; + private const int CurrentSchemaVersion = 2; private readonly string _path; public SettingsStore(string path) @@ -250,26 +251,7 @@ public sealed class SettingsStore if (toonKey is null) throw new ArgumentNullException(nameof(toonKey)); if (settings is null) throw new ArgumentNullException(nameof(settings)); - var dir = Path.GetDirectoryName(_path); - if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); - - // Read existing file as a mutable JsonObject (or start fresh). - JsonObject root; - if (File.Exists(_path)) - { - try - { - root = JsonNode.Parse(File.ReadAllText(_path)) as JsonObject ?? new JsonObject(); - } - catch - { - root = new JsonObject(); - } - } - else - { - root = new JsonObject(); - } + JsonObject root = LoadMutableRoot(); // Build the toon's payload. var toonObj = new JsonObject @@ -282,15 +264,10 @@ public sealed class SettingsStore // Slot it under character[toonKey], creating the character map if // necessary. Other toons in the map are preserved. - if (root["character"] is not JsonObject characterMap) - { - characterMap = new JsonObject(); - root["character"] = characterMap; - } + JsonObject characterMap = GetOrCreateObject(root, "character"); characterMap[toonKey] = toonObj; root["version"] = CurrentSchemaVersion; - - File.WriteAllText(_path, root.ToJsonString(new JsonSerializerOptions { WriteIndented = true })); + WriteMutableRoot(root); } /// Load a named retained-window position for one character. @@ -324,37 +301,165 @@ public sealed class SettingsStore ArgumentException.ThrowIfNullOrWhiteSpace(toonKey); ArgumentException.ThrowIfNullOrWhiteSpace(windowName); - var dir = Path.GetDirectoryName(_path); - if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); - - JsonObject root; - if (File.Exists(_path)) - { - try { root = JsonNode.Parse(File.ReadAllText(_path)) as JsonObject ?? new JsonObject(); } - catch { root = new JsonObject(); } - } - else - { - root = new JsonObject(); - } - - if (root["windowPositions"] is not JsonObject positions) - { - positions = new JsonObject(); - root["windowPositions"] = positions; - } - if (positions[toonKey] is not JsonObject toon) - { - toon = new JsonObject(); - positions[toonKey] = toon; - } + JsonObject root = LoadMutableRoot(); + JsonObject positions = GetOrCreateObject(root, "windowPositions"); + JsonObject toon = GetOrCreateObject(positions, toonKey); toon[windowName] = new JsonObject { ["x"] = position.X, ["y"] = position.Y, }; root["version"] = CurrentSchemaVersion; - File.WriteAllText(_path, root.ToJsonString(new JsonSerializerOptions { WriteIndented = true })); + WriteMutableRoot(root); + } + + /// + /// Load complete retained-window state for one character and screen resolution. + /// Missing fields in an older partial layout inherit from + /// . When no modern entry exists, the legacy + /// windowPositions[toonKey][windowName] radar entry is returned as a + /// position-only migration layered over the same fallback. + /// + public UiWindowLayout? LoadWindowLayout( + string toonKey, + string resolutionKey, + string windowName, + UiWindowLayout fallback) + { + ArgumentException.ThrowIfNullOrWhiteSpace(toonKey); + ArgumentException.ThrowIfNullOrWhiteSpace(resolutionKey); + ArgumentException.ThrowIfNullOrWhiteSpace(windowName); + if (!File.Exists(_path)) return null; + + try + { + var root = JsonNode.Parse(File.ReadAllText(_path)) as JsonObject; + JsonObject? node = root?["windowLayouts"]?[toonKey]?[resolutionKey]?[windowName] as JsonObject; + if (node is null + && root?["windowLayouts"]?[toonKey] is JsonObject resolutions + && TryParseResolution(resolutionKey, out int requestedWidth, out int requestedHeight)) + { + long bestDistance = long.MaxValue; + foreach ((string key, JsonNode? value) in resolutions) + { + if (value?[windowName] is not JsonObject candidate + || !TryParseResolution(key, out int width, out int height)) + continue; + long dx = width - requestedWidth; + long dy = height - requestedHeight; + long distance = dx * dx + dy * dy; + if (distance >= bestDistance) continue; + bestDistance = distance; + node = candidate; + } + } + + if (node is not null) + { + return new UiWindowLayout( + ReadNodeFloat(node, "x", fallback.X), + ReadNodeFloat(node, "y", fallback.Y), + ReadNodeFloat(node, "width", fallback.Width), + ReadNodeFloat(node, "height", fallback.Height), + ReadNodeBool(node, "visible", fallback.Visible), + ReadNodeBool(node, "collapsed", fallback.Collapsed), + ReadNodeBool(node, "maximized", fallback.Maximized)); + } + + // Version-1 migration: radar stored only X/Y and had no resolution key. + if (root?["windowPositions"]?[toonKey]?[windowName] is JsonObject legacy) + { + return fallback with + { + X = ReadNodeFloat(legacy, "x", fallback.X), + Y = ReadNodeFloat(legacy, "y", fallback.Y), + }; + } + + return null; + } + catch (Exception ex) + { + Console.WriteLine($"settings: failed to load window layout from {_path}: {ex.Message}"); + return null; + } + } + + /// + /// Save complete retained-window state under + /// windowLayouts[toonKey][resolutionKey][windowName], preserving every + /// other character, resolution, window, and settings section. + /// + public void SaveWindowLayout( + string toonKey, + string resolutionKey, + string windowName, + UiWindowLayout layout) + { + ArgumentException.ThrowIfNullOrWhiteSpace(toonKey); + ArgumentException.ThrowIfNullOrWhiteSpace(resolutionKey); + ArgumentException.ThrowIfNullOrWhiteSpace(windowName); + + JsonObject root = LoadMutableRoot(); + JsonObject layouts = GetOrCreateObject(root, "windowLayouts"); + JsonObject toon = GetOrCreateObject(layouts, toonKey); + JsonObject resolution = GetOrCreateObject(toon, resolutionKey); + resolution[windowName] = new JsonObject + { + ["x"] = layout.X, + ["y"] = layout.Y, + ["width"] = layout.Width, + ["height"] = layout.Height, + ["visible"] = layout.Visible, + ["collapsed"] = layout.Collapsed, + ["maximized"] = layout.Maximized, + }; + root["version"] = CurrentSchemaVersion; + WriteMutableRoot(root); + } + + private JsonObject LoadMutableRoot() + { + var dir = Path.GetDirectoryName(_path); + if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); + if (!File.Exists(_path)) return new JsonObject(); + try { return JsonNode.Parse(File.ReadAllText(_path)) as JsonObject ?? new JsonObject(); } + catch { return new JsonObject(); } + } + + private void WriteMutableRoot(JsonObject root) + => File.WriteAllText(_path, root.ToJsonString(new JsonSerializerOptions { WriteIndented = true })); + + private static JsonObject GetOrCreateObject(JsonObject parent, string key) + { + if (parent[key] is JsonObject existing) return existing; + var created = new JsonObject(); + parent[key] = created; + return created; + } + + private static float ReadNodeFloat(JsonObject node, string key, float fallback) + { + try { return node[key]?.GetValue() ?? fallback; } + catch { return fallback; } + } + + private static bool ReadNodeBool(JsonObject node, string key, bool fallback) + { + try { return node[key]?.GetValue() ?? fallback; } + catch { return fallback; } + } + + private static bool TryParseResolution(string key, out int width, out int height) + { + width = 0; + height = 0; + int separator = key.IndexOf('x', StringComparison.OrdinalIgnoreCase); + return separator > 0 + && int.TryParse(key.AsSpan(0, separator), out width) + && int.TryParse(key.AsSpan(separator + 1), out height) + && width > 0 + && height > 0; } private static SortedDictionary BuildChatObject(ChatSettings c) diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/UiWindowLayout.cs b/src/AcDream.UI.Abstractions/Panels/Settings/UiWindowLayout.cs new file mode 100644 index 00000000..2e33566c --- /dev/null +++ b/src/AcDream.UI.Abstractions/Panels/Settings/UiWindowLayout.cs @@ -0,0 +1,15 @@ +namespace AcDream.UI.Abstractions.Panels.Settings; + +/// +/// Complete persisted state for one retained window at one screen resolution. +/// Position and size are outer-frame pixels; panel flags retain the small amount +/// of state that geometry alone cannot reproduce reliably. +/// +public readonly record struct UiWindowLayout( + float X, + float Y, + float Width, + float Height, + bool Visible, + bool Collapsed, + bool Maximized); diff --git a/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs b/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs index aa8029d8..628247f0 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs @@ -180,8 +180,9 @@ public class ChatLayoutConformanceTests maxMin.OnClick!(); Assert.True(controller.IsMaximized); - Assert.Equal(90f, handle.Top); + Assert.Equal(130f, handle.Top); Assert.Equal(370f, handle.Height); // DAT max 360 + 2*5px shared chrome + Assert.Equal(500f, handle.Top + handle.Height); // lower edge stays fixed while growing upward Assert.Equal(RetailUiStateIds.Maximized, maxMin.ActiveRetailStateId); controller.Root.ApplyAnchor(handle.Width, handle.Height); Assert.Equal(360f, controller.Root.Height); diff --git a/tests/AcDream.App.Tests/UI/Layout/RetailWindowFrameTests.cs b/tests/AcDream.App.Tests/UI/Layout/RetailWindowFrameTests.cs index b3a422f1..05289a85 100644 --- a/tests/AcDream.App.Tests/UI/Layout/RetailWindowFrameTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/RetailWindowFrameTests.cs @@ -106,6 +106,59 @@ public sealed class RetailWindowFrameTests Assert.False(content.Resizable); } + [Fact] + public void HiddenInventory_RestoredExpandedBeforeFirstDraw_GrowsContentsRows() + { + ImportedLayout layout = FixtureLoader.LoadInventory(); + UiItemList grid = Assert.IsType( + layout.FindElement(InventoryController.ContentsGridId)); + grid.Columns = 6; + grid.CellWidth = 32f; + grid.CellHeight = 32f; + grid.Flush(); + for (int i = 0; i < 102; i++) + grid.AddItem(new UiItemSlot()); + + var root = new UiRoot { Width = 1280, Height = 720 }; + RetailWindowHandle handle = RetailWindowFrame.Mount( + root, + layout.Root, + NoTex, + new RetailWindowFrame.Options + { + WindowName = WindowNames.Inventory, + Chrome = RetailWindowChrome.NineSlice, + ContentWidth = layout.Root.Width, + ContentHeight = layout.Root.Height, + Visible = false, + ResizeX = false, + ResizeY = true, + ResizableEdges = ResizeEdges.Bottom, + MaxHeight = 560f, + }); + + // Production installs the controller-owned policies after mounting, while + // inventory is still hidden, and captures their design baseline immediately. + InventoryController.ConfigureResizeLayout(layout); + + // Persistence restores the hidden window before its first visible draw. + handle.ResizeTo(handle.Width, 527f); + ApplyAnchors(handle.OuterFrame); + grid.LayoutCells(); + + Assert.Equal(251f, grid.Height); + Assert.Equal(42, grid.Children.Count(child => child.Visible)); + } + + private static void ApplyAnchors(UiElement parent) + { + foreach (UiElement child in parent.Children) + { + child.ApplyAnchor(parent.Width, parent.Height); + ApplyAnchors(child); + } + } + private static ElementInfo Constraints(int minHeight, int maxHeight) { var info = new ElementInfo(); diff --git a/tests/AcDream.App.Tests/UI/RetailWindowLayoutPersistenceTests.cs b/tests/AcDream.App.Tests/UI/RetailWindowLayoutPersistenceTests.cs new file mode 100644 index 00000000..d97b96c8 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/RetailWindowLayoutPersistenceTests.cs @@ -0,0 +1,102 @@ +using AcDream.App.UI; +using AcDream.UI.Abstractions.Panels.Settings; + +namespace AcDream.App.Tests.UI; + +public sealed class RetailWindowLayoutPersistenceTests : IDisposable +{ + private readonly string _directory = Path.Combine( + Path.GetTempPath(), "acdream-window-layout-tests-" + Guid.NewGuid().ToString("N")); + private string PathName => Path.Combine(_directory, "settings.json"); + + [Fact] + public void CompletedInteractions_SaveCompleteCharacterResolutionLayout() + { + var store = new SettingsStore(PathName); + var root = new UiRoot { Width = 800, Height = 600 }; + RetailWindowHandle handle = Mount(root, "chat"); + using var persistence = new RetailWindowLayoutPersistence( + root.WindowManager, store, () => "Alice", () => (800, 600)); + + handle.MoveTo(44f, 55f); + handle.ResizeTo(320f, 180f); + handle.Hide(); + + UiWindowLayout saved = Assert.IsType( + store.LoadWindowLayout("Alice", "800x600", "chat", default)); + Assert.Equal((44f, 55f, 320f, 180f), (saved.X, saved.Y, saved.Width, saved.Height)); + Assert.False(saved.Visible); + } + + [Fact] + public void Restore_ClampsBoundsAndReappliesPanelStateAndVisibility() + { + var store = new SettingsStore(PathName); + store.SaveWindowLayout( + "Alice", + "640x480", + "chat", + new UiWindowLayout(900f, 700f, 500f, 300f, false, false, true)); + + var root = new UiRoot { Width = 640, Height = 480 }; + var state = new FakeWindowState(); + RetailWindowHandle handle = Mount(root, "chat", state); + using var persistence = new RetailWindowLayoutPersistence( + root.WindowManager, store, () => "Alice", () => (640, 480)); + + persistence.RestoreAll(); + + Assert.Equal((500f, 300f), (handle.Width, handle.Height)); + Assert.Equal((140f, 180f), (handle.Left, handle.Top)); + Assert.False(handle.IsVisible); + Assert.True(state.Restored.Maximized); + } + + [Fact] + public void DefaultPreLoginKey_NeverOverwritesCharacterLayouts() + { + var store = new SettingsStore(PathName); + var root = new UiRoot { Width = 800, Height = 600 }; + RetailWindowHandle handle = Mount(root, "radar"); + using var persistence = new RetailWindowLayoutPersistence( + root.WindowManager, store, () => "default", () => (800, 600)); + + handle.MoveTo(123f, 234f); + + Assert.Null(store.LoadWindowLayout("default", "800x600", "radar", default)); + } + + private static RetailWindowHandle Mount( + UiRoot root, + string name, + IRetainedWindowStateController? state = null) + { + var frame = new UiPanel + { + Left = 10f, + Top = 20f, + Width = 200f, + Height = 100f, + MinWidth = 100f, + MinHeight = 80f, + MaxWidth = 600f, + MaxHeight = 400f, + Draggable = true, + Resizable = true, + }; + root.AddChild(frame); + return root.RegisterWindow(name, frame, stateController: state); + } + + public void Dispose() + { + if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true); + } + + private sealed class FakeWindowState : IRetainedWindowStateController + { + public RetainedWindowState Restored { get; private set; } + public RetainedWindowState CaptureWindowState() => Restored; + public void RestoreWindowState(RetainedWindowState state) => Restored = state; + } +} diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs index 519c5f41..59e06fa1 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs @@ -384,4 +384,54 @@ public sealed class SettingsStoreTests : System.IDisposable Assert.Null(store.LoadWindowPosition("Alice", "inventory")); Assert.True(store.LoadGameplay().LockUI); } + + [Fact] + public void WindowLayouts_RoundTripPerCharacterResolutionAndPreserveSettings() + { + var store = new SettingsStore(_tempPath); + store.SaveGameplay(GameplaySettings.Default with { LockUI = true }); + var alice1080 = new UiWindowLayout(10f, 20f, 500f, 300f, true, false, true); + var alice1440 = new UiWindowLayout(30f, 40f, 650f, 420f, false, true, false); + var bob1080 = new UiWindowLayout(50f, 60f, 310f, 132f, true, false, false); + + store.SaveWindowLayout("Alice", "1920x1080", "chat", alice1080); + store.SaveWindowLayout("Alice", "2560x1440", "chat", alice1440); + store.SaveWindowLayout("Bob", "1920x1080", "toolbar", bob1080); + + Assert.Equal(alice1080, store.LoadWindowLayout("Alice", "1920x1080", "chat", default)); + Assert.Equal(alice1440, store.LoadWindowLayout("Alice", "2560x1440", "chat", default)); + Assert.Equal(bob1080, store.LoadWindowLayout("Bob", "1920x1080", "toolbar", default)); + Assert.Null(store.LoadWindowLayout("Alice", "1920x1080", "inventory", default)); + Assert.True(store.LoadGameplay().LockUI); + } + + [Fact] + public void WindowLayouts_MigrateLegacyRadarPositionOverCurrentDefaults() + { + var store = new SettingsStore(_tempPath); + store.SaveWindowPosition("Alice", "radar", new UiWindowPosition(321.5f, 18f)); + var current = new UiWindowLayout(10f, 10f, 160f, 180f, true, false, false); + + UiWindowLayout migrated = Assert.IsType( + store.LoadWindowLayout("Alice", "1920x1080", "radar", current)); + + Assert.Equal(321.5f, migrated.X); + Assert.Equal(18f, migrated.Y); + Assert.Equal(current.Width, migrated.Width); + Assert.Equal(current.Height, migrated.Height); + Assert.Equal(current.Visible, migrated.Visible); + } + + [Fact] + public void WindowLayouts_FallBackToNearestSavedResolution() + { + var store = new SettingsStore(_tempPath); + var hd = new UiWindowLayout(100f, 110f, 400f, 250f, true, false, false); + var uhd = new UiWindowLayout(300f, 310f, 600f, 450f, true, false, false); + store.SaveWindowLayout("Alice", "1280x720", "chat", hd); + store.SaveWindowLayout("Alice", "3840x2160", "chat", uhd); + + Assert.Equal(hd, store.LoadWindowLayout("Alice", "1600x900", "chat", default)); + Assert.Equal(uhd, store.LoadWindowLayout("Alice", "3200x1800", "chat", default)); + } }