From a8e9503d2e527d046a93df3ddbf1bbbc7ec03562 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 10 Jul 2026 22:22:25 +0200 Subject: [PATCH] feat(ui): unify retained window mounts --- .../retail-divergence-register.md | 8 +- ...026-07-10-retail-ui-fidelity-completion.md | 14 +- src/AcDream.App/Rendering/GameWindow.cs | 196 +++++++--------- src/AcDream.App/Studio/MockupDesktop.cs | 214 +++++++++--------- .../UI/Layout/CharacterStatController.cs | 10 +- .../UI/Layout/ChatWindowController.cs | 110 ++++++--- src/AcDream.App/UI/Layout/DatWidgetFactory.cs | 64 +++++- .../UI/Layout/InventoryController.cs | 23 +- .../UI/Layout/RetailWindowFrame.cs | 202 +++++++++++------ src/AcDream.App/UI/README.md | 2 + src/AcDream.App/UI/RetailWindowManager.cs | 2 +- src/AcDream.App/UI/UiElement.cs | 3 +- src/AcDream.App/UI/UiRoot.cs | 2 +- src/AcDream.App/UI/UiScrollable.cs | 19 ++ src/AcDream.App/UI/UiScrollbar.cs | 15 +- src/AcDream.App/UI/UiText.cs | 11 +- .../UI/Layout/CharacterStatControllerTests.cs | 4 +- .../UI/Layout/ChatLayoutConformanceTests.cs | 143 ++++++++++++ .../UI/Layout/RetailWindowFrameTests.cs | 126 +++++++++++ .../AcDream.App.Tests/UI/UiScrollableTests.cs | 21 ++ 20 files changed, 823 insertions(+), 366 deletions(-) create mode 100644 tests/AcDream.App.Tests/UI/Layout/RetailWindowFrameTests.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 6add193c..4132c333 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -54,8 +54,8 @@ 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 now centralize registry, raise, focus/capture cleanup, lifecycle events, and controller teardown. Production LayoutDesc imports include vitals `0x2100006C`, chat `0x21000006`, toolbar `0x21000016`, character `0x2100002E`, inventory `0x21000023` plus mounted `0x21000024/22/21`, and radar `0x21000074`; chrome/mount paths remain mixed pending Wave 2.2. | `src/AcDream.App/UI/RetailWindowManager.cs`; `src/AcDream.App/UI/Layout/LayoutImporter.cs`; `src/AcDream.App/UI/Layout/RetailWindowFrame.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. | Mixed mount/chrome paths or unported persistence semantics can make windows drift from one another at non-reference sizes or across restart; 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-17 | Toolbar window FRAME is toolkit-supplied (per-window UiNineSlicePanel 8-piece bevel, drawn over content via UiElement.OnDrawAfterChildren) rather than the window-manager-owned chrome retail paints uniformly around every window. 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/Rendering/GameWindow.cs` (toolbar mount) + `src/AcDream.App/UI/UiNineSlicePanel.cs` + `src/AcDream.App/UI/UiCollapsibleFrame.cs`; spec: `docs/superpowers/specs/2026-06-20-d2b-toolbar-collapse-design.md` | LayoutDesc 0x21000016 has NO baked frame; retail's toolbar frame is window-manager chrome (keystone.dll). We draw the same reusable 8-piece bevel chat/vitals use; border drawn over content so the toolbar's 2px-wide row-2 right cap (W=8) can't poke through. Same pattern as the chat window. | Until a central window manager owns chrome uniformly, per-window wraps can drift (size/offset/z-order) from each other and from retail; the border-over-content rule is the toolkit's, not the WM's | gmToolbarUI WM chrome (keystone.dll, no PDB); no bevel ids in LayoutDesc 0x21000016 (toolbar dump) | +| 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-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,14 +203,14 @@ 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.1)** — typed handles and `RetailWindowManager` now own registry, raise, lifecycle, focus/capture cleanup, and controller disposal. Complete layout persistence is still absent, and chat still maximizes imported content to hardcoded 320 px instead of resizing the outer frame. | `src/AcDream.App/UI/RetailWindowManager.cs`; `RetailWindowHandle.cs`; `ChatWindowController.cs`; production mounts in `GameWindow.cs` | Window lifecycle now has one tested owner; Wave 2.2/2.3 migrate mounts and persistence | Layouts still reset/drift across restart, and chat chrome/content disagree after maximize | `gmMainChatUI::HandleMaximizeButton @ 0x004CCE50`; retail saveui/loadui behavior | +| 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-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` | | AP-102 | Inventory-to-occupied toolbar drop treats the inventory source index as though it were a vacated toolbar slot for the displaced shortcut | `src/AcDream.App/UI/Layout/ToolbarController.cs` drop planner | Normal empty-slot and toolbar-reorder paths work; Wave 4 makes the transaction source-aware | The displaced shortcut is lost or moved to an unrelated slot | `gmToolbarUI::HandleDropRelease @ 0x004BE7C0` | | AP-103 | Shortcut session storage keeps only object guid and discards raw non-object fields; parser/builder names the final uint as `u16 spellId/u16 layer` instead of preserving retail `spellID_` as raw data | `src/AcDream.Core/Items/ShortcutStore.cs`; `src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs`; `InventoryActions.cs` | `gmToolbarUI` itself displays only nonzero object ids, so current item shortcuts work | Round-trip mutation corrupts unknown/raw fields and encourages invented spell support in the item toolbar | `ShortCutData` in `acclient.h:36484`; `gmToolbarUI::UpdateFromPlayerDesc @ 0x004BF810` | | AP-104 | Vitals detail element `0x100004A9` and root `HideDetail`/`ShowDetail` transitions are not wired | `src/AcDream.App/UI/Layout/VitalsController.cs` | Compact vitals values/bars are correct | Detail click does nothing and expanded retail state is unreachable | `gmVitalsUI::ListenToElementMessage @ 0x004BFC00`; `PostInit @ 0x004BFCE0` | -| AP-105 | Retained chat lacks complete tab/filter/unread, social availability, squelch, and focus-opacity behavior; scrollbar arrow roles are reversed and a second ChatVM loses FPS/position providers | `src/AcDream.App/UI/Layout/ChatWindowController.cs`; chat mount in `GameWindow.cs` | Shared log/send path and word wrapping work; Waves 2/5 consolidate the remaining state | Commands degrade, tabs are no-ops, moderation/channel state is wrong, and focus visuals diverge | `gmMainChatUI @ 0x004CCCC0..0x004CE2A0`; `ChatInterface` methods | +| AP-105 | **PARTIAL 2026-07-10 (Wave 2.2)** — inherited scrollbar media/roles now come from DAT (decrement/top `0x06004C69`, increment/bottom `0x06004C6C`). Retained chat still lacks complete tab/filter/unread, social availability, squelch, and focus-opacity behavior; a second ChatVM still loses FPS/position providers. | `src/AcDream.App/UI/Layout/DatWidgetFactory.cs`; `ChatWindowController.cs`; chat mount in `GameWindow.cs` | Shared log/send path, wrapping, scrollbar roles, and outer maximize geometry work; Wave 5 consolidates remaining chat state | Commands degrade, tabs are no-ops, moderation/channel state is wrong, and focus visuals diverge | `gmMainChatUI @ 0x004CCCC0..0x004CE2A0`; `UIElement_Scrollbar::OnSetAttribute @ 0x004714D0`; `ChatInterface` methods | | AP-106 | The retained UI has no external/ground-container window lifecycle, while the original owned-side-bag `NoLongerViewingContents` premise was incorrect (#196) | `src/AcDream.App/UI`; window runtime; absent external-container controller | Owned inventory navigation remains usable and must not gain an unproven `0x0195` send; Wave 2/6 adds lifecycle ownership and the separate external surface | External container replacement/close cannot notify the server exactly once; adding the packet to owned bag close would itself diverge from the named retail call graph | `ClientUISystem.groundObject`; `CM_Inventory::Event_NoLongerViewingContents @ 0x006ABC50`; `docs/research/2026-07-10-retail-panel-behavior-pseudocode.md` | | AP-107 | Inventory grid/bag clicks bypass active target-mode acquisition while cursor resolution treats occupied cells as targets (#197) | `src/AcDream.App/UI/Layout/InventoryController.cs`; `UiItemSlot.cs` | Main pack/toolbar/paperdoll already opt in at their call sites | A valid-target cursor click selects/opens rather than consuming the target | `UIElement_ItemList::HandleTargetedUseLeftClick @ 0x004E24D0` | | AP-108 | Paperdoll omits Aetheria, authoritative upper-item priority, full `AutoWieldIsLegal`/dual-wield rules, and body-part interaction/highlighting | `src/AcDream.App/UI/Layout/PaperdollController.cs` | Basic equip slots, live doll, toggle, and optimistic wield work | Layered clothing, special slots, invalid drops, and doll-body clicks differ functionally | `gmPaperDollUI @ 0x004A3590..0x004A5F90`; `InventoryPlacement::DetermineHigherPriority` | 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 aedb01b6..c15a29a3 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 @@ -241,17 +241,21 @@ text; full suite green; user visual gate. ### 2.2 Use one mount path for every production window -- [ ] Extend `RetailWindowFrame.Mount` to return a handle and accept exact resize, +- [x] Extend `RetailWindowFrame.Mount` to return a handle and accept exact resize, chrome, opacity, visibility and constraint policy. -- [ ] Support both imported roots that already own retail chrome and shared +- [x] Support both imported roots that already own retail chrome and shared nine-slice wrappers, with independent X/Y resize policy, so migrations cannot double-frame or double-inset content. -- [ ] Migrate vitals, chat, toolbar, inventory, radar, character, and Studio one at +- [x] Migrate vitals, chat, toolbar, inventory, radar, character, and Studio one at a time. Delete all inline `UiNineSlicePanel` mount recipes from `GameWindow` and `MockupDesktop`. -- [ ] Make chat maximize operate on its handle's outer frame and import retail +- [x] Make chat maximize operate on its handle's outer frame and import retail min/max attributes and button states; remove hardcoded `320f`. -- [ ] Correct chat scrollbar art from the actual inherited DAT roles, not literals. +- [x] Correct chat scrollbar art from the actual inherited DAT roles, not literals. + +**Acceptance (2026-07-10):** full suite green (4,538 passed, 5 skipped); live +Debug gate passed for shared chrome, resize/collapse, lock/raise, chat maximize, +scrollbar buttons, track/thumb dragging, wheel scrolling, and bottom-pin behavior. ### 2.3 Persist the complete UI layout diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 696cd9a5..5665a995 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -2099,22 +2099,20 @@ public sealed class GameWindow : IDisposable healthText: () => (_vitalsVm!.HealthCurrent, _vitalsVm.HealthMax) is (uint c, uint m) ? $"{c}/{m}" : "", staminaText: () => (_vitalsVm!.StaminaCurrent, _vitalsVm.StaminaMax) is (uint c, uint m) ? $"{c}/{m}" : "", manaText: () => (_vitalsVm!.ManaCurrent, _vitalsVm.ManaMax) is (uint c, uint m) ? $"{c}/{m}" : ""); - // Top-level retail window: user-positioned (Anchors.None so the per-frame - // anchor pass doesn't reset it), movable, and horizontally resizable like - // retail. On a width change the dat edge-anchors reflow the pieces - // (UIElement::UpdateForParentSizeChange @0x00462640): top/bottom edges + - // the three bars stretch, corners stay 5px, the right edge/corners track - // the right side. Vertical resize is off (the layout has no vertical stretch). var vitalsRoot = imported.Root; - vitalsRoot.Left = 10; vitalsRoot.Top = 30; - vitalsRoot.ClickThrough = false; - vitalsRoot.Anchors = AcDream.App.UI.AnchorEdges.None; - vitalsRoot.Draggable = true; - vitalsRoot.Resizable = true; - vitalsRoot.ResizeX = true; - vitalsRoot.ResizeY = false; - vitalsRoot.MinWidth = 40f; - _uiHost.Root.AddChild(vitalsRoot); + AcDream.App.UI.Layout.RetailWindowFrame.Mount( + _uiHost.Root, vitalsRoot, ResolveChrome, + new AcDream.App.UI.Layout.RetailWindowFrame.Options + { + WindowName = AcDream.App.UI.WindowNames.Vitals, + Chrome = AcDream.App.UI.Layout.RetailWindowChrome.Imported, + Left = 10f, + Top = 30f, + ResizeX = true, + ResizeY = false, + MinWidth = 40f, + ContentClickThrough = false, + }); Console.WriteLine("[D.2b] retail UI active — vitals window from LayoutDesc importer (0x2100006C)."); } else @@ -2150,16 +2148,20 @@ public sealed class GameWindow : IDisposable setUiLocked: SetRetailUiLocked, datFont: vitalsDatFont); - radarRoot.Left = System.Math.Max(0f, _window!.Size.X - radarRoot.Width - 10f); - radarRoot.Top = 10f; - radarRoot.ClickThrough = false; - // User-positioned top-level window: Anchors.None prevents the - // per-frame anchor pass from undoing drag-handle movement. - radarRoot.Anchors = AcDream.App.UI.AnchorEdges.None; - radarRoot.Resizable = false; - radarRoot.ConstrainDragToParent = true; - _uiHost.Root.AddChild(radarRoot); - _uiHost.RegisterWindow(AcDream.App.UI.WindowNames.Radar, radarRoot); + AcDream.App.UI.Layout.RetailWindowFrame.Mount( + _uiHost.Root, radarRoot, ResolveChrome, + new AcDream.App.UI.Layout.RetailWindowFrame.Options + { + WindowName = AcDream.App.UI.WindowNames.Radar, + Chrome = AcDream.App.UI.Layout.RetailWindowChrome.Imported, + Left = System.Math.Max(0f, _window!.Size.X - radarRoot.Width - 10f), + Top = 10f, + Resizable = false, + ResizeX = false, + ResizeY = false, + ConstrainDragToParent = true, + ContentClickThrough = false, + }); ApplySavedRadarPosition(); Console.WriteLine("[D.6] retail radar/compass from LayoutDesc 0x21000074."); } @@ -2204,32 +2206,30 @@ public sealed class GameWindow : IDisposable // HEIGHT so the content's child anchors (input-bar-at-bottom, transcript- // fills) capture correct margins on first layout; resizing the frame reflows // them correctly from there. - const int chatBorder = AcDream.App.UI.RetailChromeSprites.Border; var chatRoot = chatController.Root; - float contentW = 490f, contentH = chatRoot.Height; // dat-authored height - var chatFrame = new AcDream.App.UI.UiNineSlicePanel(ResolveChrome) - { - Left = 10, Top = 440, - Width = contentW + 2 * chatBorder, Height = contentH + 2 * chatBorder, - MinWidth = 200f, MinHeight = 90f, - // Retail chat is translucent — fade the window's backgrounds/chrome - // (text stays opaque). Configurable opacity is a later step; 0.75 reads - // as see-through-but-readable. (retail SetDefaultOpacity ~0.5 / active 1.0) - Opacity = 0.75f, - }; - chatRoot.Left = chatBorder; chatRoot.Top = chatBorder; - chatRoot.Width = contentW; chatRoot.Height = contentH; - chatRoot.Anchors = AcDream.App.UI.AnchorEdges.Left | AcDream.App.UI.AnchorEdges.Top - | AcDream.App.UI.AnchorEdges.Right | AcDream.App.UI.AnchorEdges.Bottom; + var chatHandle = AcDream.App.UI.Layout.RetailWindowFrame.Mount( + _uiHost.Root, chatRoot, ResolveChrome, + new AcDream.App.UI.Layout.RetailWindowFrame.Options + { + WindowName = AcDream.App.UI.WindowNames.Chat, + Chrome = AcDream.App.UI.Layout.RetailWindowChrome.NineSlice, + Left = 10f, + Top = 440f, + ContentWidth = 490f, + ContentHeight = chatRoot.Height, + RebaseContentLayout = true, + DatConstraintSource = chatController.DatWindowInfo, + MinWidth = 200f, + ResizeX = true, + ResizeY = true, + Opacity = 0.75f, + }); + chatController.AttachWindow(chatHandle); // The LayoutDesc root is 800px wide because it shares the display // coordinate space with auxiliary/floaty chat elements. The docked // gmMainChatUI content is the left 490px. Rebase its immediate children // after that intentional crop so subsequent frame resizing grows the // transcript/input across the whole mounted interior. - chatRoot.RebaseChildLayoutBaselines(); - chatRoot.Draggable = false; chatRoot.Resizable = false; - chatFrame.AddChild(chatRoot); - _uiHost.Root.AddChild(chatFrame); // Tab / Enter enters "write mode" by focusing this input (retail's chat // activation); a focused input suppresses character movement (see the // WantsKeyboard gate in the movement poll). @@ -2365,34 +2365,26 @@ public sealed class GameWindow : IDisposable // thickness on every side, giving an outer window of 310×132. const int toolbarBorder = AcDream.App.UI.RetailChromeSprites.Border; float toolbarContentW = 300f, toolbarContentH = toolbarRoot.Height; - var toolbarFrame = new AcDream.App.UI.UiCollapsibleFrame(ResolveChrome) - { - Left = 10, Top = 300, - Width = toolbarContentW + 2 * toolbarBorder, - Height = toolbarContentH + 2 * toolbarBorder, - // The toolbar is fully opaque (not translucent like the chat window). - Opacity = 1.0f, - }; - // Content is offset by the border so it sits inside the chrome. - toolbarRoot.Left = toolbarBorder; - toolbarRoot.Top = toolbarBorder; - toolbarRoot.Width = toolbarContentW; - toolbarRoot.Height = toolbarContentH; - // Anchor content to Left|Top|Right only — drop Bottom so the dat content - // keeps its full height when the frame collapses; row 2 hides via Visible, - // not reflow. (Width is fixed so the horizontal anchors are inert but harmless.) - toolbarRoot.Anchors = AcDream.App.UI.AnchorEdges.Left | AcDream.App.UI.AnchorEdges.Top - | AcDream.App.UI.AnchorEdges.Right; - // The frame is the draggable window; the content itself is not. ClickThrough so the - // content panel never CLAIMS a hit — its behavioral children (slots, indicators) are - // hit children-first, and its empty areas fall through to the frame (move). Critically, - // when collapsed the row-2 band is hidden, so below the collapsed frame the content has - // no visible/hit children and ClickThrough lets clicks fall through (no phantom - // window-drag from where row 2 used to be). Same pattern as the chat content panel. - toolbarRoot.ClickThrough = true; - toolbarRoot.Draggable = false; - toolbarRoot.Resizable = false; - toolbarFrame.AddChild(toolbarRoot); + var toolbarHandle = AcDream.App.UI.Layout.RetailWindowFrame.Mount( + _uiHost.Root, toolbarRoot, ResolveChrome, + new AcDream.App.UI.Layout.RetailWindowFrame.Options + { + WindowName = AcDream.App.UI.WindowNames.Toolbar, + Chrome = AcDream.App.UI.Layout.RetailWindowChrome.CollapsibleNineSlice, + Left = 10f, + Top = 300f, + ContentWidth = toolbarContentW, + ContentHeight = toolbarContentH, + Resizable = false, + ResizeX = false, + ResizeY = true, + ResizableEdges = AcDream.App.UI.ResizeEdges.Bottom, + ContentAnchors = AcDream.App.UI.AnchorEdges.Left + | AcDream.App.UI.AnchorEdges.Top + | AcDream.App.UI.AnchorEdges.Right, + ContentClickThrough = true, + }); + var toolbarFrame = (AcDream.App.UI.UiCollapsibleFrame)toolbarHandle.OuterFrame; // Collapse-to-one-row: the frame's bottom edge snaps between a one-row (row 2 hidden) // and two-row height. CollapsedHeight is computed from the layout (just above row 2), @@ -2431,8 +2423,6 @@ public sealed class GameWindow : IDisposable Console.WriteLine($"[D.2b] toolbar collapse: collapsed={collapsedH:0} expanded={expandedH:0} (row2Top={minRow2Top:0})."); } - _uiHost.Root.AddChild(toolbarFrame); - Console.WriteLine("[D.5.1] retail toolbar window from LayoutDesc importer (0x21000016)."); } else Console.WriteLine("[D.5.1] toolbar: LayoutDesc 0x21000016 not found."); @@ -2462,16 +2452,18 @@ public sealed class GameWindow : IDisposable _uiHost.Root, characterLayout.Root, ResolveChrome, new AcDream.App.UI.Layout.RetailWindowFrame.Options { + WindowName = AcDream.App.UI.WindowNames.Character, + Chrome = AcDream.App.UI.Layout.RetailWindowChrome.NineSlice, Left = 540f, Top = 18f, MaxHeight = 760f, ResizeX = false, + ResizeY = true, ResizableEdges = AcDream.App.UI.ResizeEdges.Bottom, Visible = false, ContentAnchors = AcDream.App.UI.AnchorEdges.Left | AcDream.App.UI.AnchorEdges.Top | AcDream.App.UI.AnchorEdges.Bottom, ContentClickThrough = false, - WindowName = AcDream.App.UI.WindowNames.Character, }); Console.WriteLine("[D.2b-C] retail character window from LayoutDesc importer (0x2100002E)."); @@ -2515,36 +2507,27 @@ public sealed class GameWindow : IDisposable // SAME UiNineSlicePanel the vitals/chat/toolbar windows use. The gmInventoryUI // LayoutDesc (0x21000023) is 300×362; the frame adds one border thickness on // every side. The frame is the draggable window; the dat content sits inside it. - const int invBorder = AcDream.App.UI.RetailChromeSprites.Border; float invContentW = inventoryRoot.Width, invContentH = inventoryRoot.Height; - var inventoryFrame = new AcDream.App.UI.UiNineSlicePanel(ResolveChrome) - { - Left = inventoryRoot.Left, Top = inventoryRoot.Top, // keep the dat window position - Width = invContentW + 2 * invBorder, - Height = invContentH + 2 * invBorder, - Opacity = 1.0f, - Visible = false, // F12 toggles it - Anchors = AcDream.App.UI.AnchorEdges.None, // user-positioned - Draggable = true, - }; - // Content offset by the border so it sits inside the chrome. - inventoryRoot.Left = invBorder; inventoryRoot.Top = invBorder; - inventoryRoot.Width = invContentW; inventoryRoot.Height = invContentH; - // Content fills the frame vertically (Left|Top|Bottom = fixed width + height tracks - // the frame) so a vertical resize grows the inner content. - inventoryRoot.Anchors = AcDream.App.UI.AnchorEdges.Left | AcDream.App.UI.AnchorEdges.Top - | AcDream.App.UI.AnchorEdges.Bottom; - inventoryRoot.Draggable = false; - inventoryFrame.AddChild(inventoryRoot); - - // Vertical-only resize: drag the BOTTOM edge to expand and see more of the pack. - // ResizeX=false + ResizableEdges=Bottom blocks horizontal resize (user request). - // MinHeight = the dat default (no shrink below it); MaxHeight = toward full screen. - inventoryFrame.Resizable = true; - inventoryFrame.ResizeX = false; - inventoryFrame.ResizableEdges = AcDream.App.UI.ResizeEdges.Bottom; - inventoryFrame.MinHeight = invContentH + 2 * invBorder; - inventoryFrame.MaxHeight = 560f; + var inventoryHandle = AcDream.App.UI.Layout.RetailWindowFrame.Mount( + _uiHost.Root, inventoryRoot, ResolveChrome, + new AcDream.App.UI.Layout.RetailWindowFrame.Options + { + WindowName = AcDream.App.UI.WindowNames.Inventory, + Chrome = AcDream.App.UI.Layout.RetailWindowChrome.NineSlice, + Left = inventoryRoot.Left, + Top = inventoryRoot.Top, + ContentWidth = invContentW, + ContentHeight = invContentH, + Visible = false, + ResizeX = false, + ResizeY = true, + ResizableEdges = AcDream.App.UI.ResizeEdges.Bottom, + MaxHeight = 560f, + ContentAnchors = AcDream.App.UI.AnchorEdges.Left + | AcDream.App.UI.AnchorEdges.Top + | AcDream.App.UI.AnchorEdges.Bottom, + }); + 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 @@ -2568,9 +2551,6 @@ public sealed class GameWindow : IDisposable PinTopLeft(0x100001CDu); // paperdoll (fixed at top) PinTopLeft(0x100001CEu); // side-bag column (fixed at top) - _uiHost.Root.AddChild(inventoryFrame); - _uiHost.RegisterWindow(AcDream.App.UI.WindowNames.Inventory, inventoryFrame); - // Phase D.2b-B — populate the inventory from ClientObjectTable: the // "Contents of Backpack" grid + the pack-selector strip + the burden meter + // captions. Analogue of gmInventoryUI/gmBackpackUI/gm3DItemsUI::PostInit. diff --git a/src/AcDream.App/Studio/MockupDesktop.cs b/src/AcDream.App/Studio/MockupDesktop.cs index 8ef57909..ff6c2b06 100644 --- a/src/AcDream.App/Studio/MockupDesktop.cs +++ b/src/AcDream.App/Studio/MockupDesktop.cs @@ -23,15 +23,15 @@ internal static class MockupDesktop var windows = new List(); if (MountVitals(dats, stack, objects) is { } vitals) - RegisterWindow(stack, windows, WindowNames.Vitals, "Vitals", vitals, zOrder: 10); + RecordWindow(windows, "Vitals", vitals, zOrder: 10); if (MountToolbar(dats, stack, objects) is { } toolbar) - RegisterWindow(stack, windows, WindowNames.Toolbar, "Toolbar", toolbar, zOrder: 20); + RecordWindow(windows, "Toolbar", toolbar, zOrder: 20); if (MountCharacter(dats, stack, objects) is { } character) - RegisterWindow(stack, windows, WindowNames.Character, "Character", character, zOrder: 30); + RecordWindow(windows, "Character", character, zOrder: 30); if (MountInventory(dats, stack, objects) is { } inventory) - RegisterWindow(stack, windows, WindowNames.Inventory, "Inventory", inventory, zOrder: 40); + RecordWindow(windows, "Inventory", inventory, zOrder: 40); if (MountChat(dats, stack) is { } chat) - RegisterWindow(stack, windows, WindowNames.Chat, "Chat", chat, zOrder: 50); + RecordWindow(windows, "Chat", chat, zOrder: 50); MountControls(stack, windows); } @@ -40,27 +40,31 @@ internal static class MockupDesktop => LayoutImporter.Import(dats, layoutId, stack.ResolveChrome, stack.VitalsDatFont, fontResolve: stack.ResolveDatFont); - private static UiElement? MountVitals(DatCollection dats, RenderStack stack, AcDream.Core.Items.ClientObjectTable objects) + private static RetailWindowHandle? MountVitals(DatCollection dats, RenderStack stack, AcDream.Core.Items.ClientObjectTable objects) { var layout = Import(dats, 0x2100006Cu, stack); if (layout is null) return null; FixtureProvider.Populate(0x2100006Cu, layout, stack, objects, dats); - var root = layout.Root; - root.Left = 12f; - root.Top = 18f; - root.ClickThrough = false; - root.Anchors = AnchorEdges.None; - root.Draggable = true; - root.Resizable = true; - root.ResizeX = true; - root.ResizeY = false; - root.MinWidth = 40f; - return root; + return RetailWindowFrame.Mount( + stack.UiHost.Root, + layout.Root, + stack.ResolveChrome, + new RetailWindowFrame.Options + { + WindowName = WindowNames.Vitals, + Chrome = RetailWindowChrome.Imported, + Left = 12f, + Top = 18f, + ResizeX = true, + ResizeY = false, + MinWidth = 40f, + ContentClickThrough = false, + }); } - private static UiElement? MountToolbar(DatCollection dats, RenderStack stack, AcDream.Core.Items.ClientObjectTable objects) + private static RetailWindowHandle? MountToolbar(DatCollection dats, RenderStack stack, AcDream.Core.Items.ClientObjectTable objects) { var layout = Import(dats, 0x21000016u, stack); if (layout is null) return null; @@ -71,27 +75,29 @@ internal static class MockupDesktop var toolbarRoot = layout.Root; float contentW = toolbarRoot.Width > 0f ? toolbarRoot.Width : 300f; float contentH = toolbarRoot.Height; - var frame = new UiCollapsibleFrame(stack.ResolveChrome) - { - Left = 12f, - Top = 220f, - Width = contentW + 2 * border, - Height = contentH + 2 * border, - Opacity = 1f, - }; - - toolbarRoot.Left = border; - toolbarRoot.Top = border; - toolbarRoot.Width = contentW; - toolbarRoot.Height = contentH; - toolbarRoot.Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right; - toolbarRoot.ClickThrough = true; - toolbarRoot.Draggable = false; - toolbarRoot.Resizable = false; - frame.AddChild(toolbarRoot); + RetailWindowHandle handle = RetailWindowFrame.Mount( + stack.UiHost.Root, + toolbarRoot, + stack.ResolveChrome, + new RetailWindowFrame.Options + { + WindowName = WindowNames.Toolbar, + Chrome = RetailWindowChrome.CollapsibleNineSlice, + Left = 12f, + Top = 220f, + ContentWidth = contentW, + ContentHeight = contentH, + Resizable = false, + ResizeX = false, + ResizeY = true, + ResizableEdges = ResizeEdges.Bottom, + ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right, + ContentClickThrough = true, + }); + var frame = (UiCollapsibleFrame)handle.OuterFrame; ConfigureToolbarCollapse(layout, frame, contentH, border); - return frame; + return handle; } private static void ConfigureToolbarCollapse(ImportedLayout layout, UiCollapsibleFrame frame, float contentH, int border) @@ -126,60 +132,60 @@ internal static class MockupDesktop frame.MaxHeight = expandedH; } - private static UiElement? MountCharacter(DatCollection dats, RenderStack stack, AcDream.Core.Items.ClientObjectTable objects) + private static RetailWindowHandle? MountCharacter(DatCollection dats, RenderStack stack, AcDream.Core.Items.ClientObjectTable objects) { var layout = Import(dats, 0x2100002Eu, stack); if (layout is null) return null; FixtureProvider.Populate(0x2100002Eu, layout, stack, objects, dats); - var root = layout.Root; - root.Left = 540f; - root.Top = 18f; - root.Anchors = AnchorEdges.None; - root.ClickThrough = false; - root.Draggable = true; - root.Resizable = true; - root.MinWidth = root.Width; - root.MinHeight = root.Height; - return root; + return RetailWindowFrame.Mount( + stack.UiHost.Root, + layout.Root, + stack.ResolveChrome, + new RetailWindowFrame.Options + { + WindowName = WindowNames.Character, + Chrome = RetailWindowChrome.NineSlice, + Left = 540f, + Top = 18f, + MaxHeight = 760f, + ResizeX = false, + ResizeY = true, + ResizableEdges = ResizeEdges.Bottom, + ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom, + ContentClickThrough = false, + }); } - private static UiElement? MountInventory(DatCollection dats, RenderStack stack, AcDream.Core.Items.ClientObjectTable objects) + private static RetailWindowHandle? MountInventory(DatCollection dats, RenderStack stack, AcDream.Core.Items.ClientObjectTable objects) { var layout = Import(dats, 0x21000023u, stack); if (layout is null) return null; FixtureProvider.Populate(0x21000023u, layout, stack, objects, dats); - const int border = RetailChromeSprites.Border; var inventoryRoot = layout.Root; float contentW = inventoryRoot.Width; float contentH = inventoryRoot.Height; - var frame = new UiNineSlicePanel(stack.ResolveChrome) - { - Left = 900f, - Top = 18f, - Width = contentW + 2 * border, - Height = contentH + 2 * border, - Opacity = 1f, - Visible = true, - Anchors = AnchorEdges.None, - Draggable = true, - Resizable = true, - ResizeX = false, - ResizableEdges = ResizeEdges.Bottom, - MinHeight = contentH + 2 * border, - MaxHeight = 560f, - }; - - inventoryRoot.Left = border; - inventoryRoot.Top = border; - inventoryRoot.Width = contentW; - inventoryRoot.Height = contentH; - inventoryRoot.Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom; - inventoryRoot.Draggable = false; - frame.AddChild(inventoryRoot); + RetailWindowHandle handle = RetailWindowFrame.Mount( + stack.UiHost.Root, + inventoryRoot, + stack.ResolveChrome, + new RetailWindowFrame.Options + { + WindowName = WindowNames.Inventory, + Chrome = RetailWindowChrome.NineSlice, + Left = 900f, + Top = 18f, + ContentWidth = contentW, + ContentHeight = contentH, + ResizeX = false, + ResizeY = true, + ResizableEdges = ResizeEdges.Bottom, + MaxHeight = 560f, + ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom, + }); StretchV(layout, 0x100001D0u); StretchV(layout, 0x100001CFu); @@ -188,7 +194,7 @@ internal static class MockupDesktop PinTopLeft(layout, 0x100001CDu); PinTopLeft(layout, 0x100001CEu); - return frame; + return handle; } private static void StretchV(ImportedLayout layout, uint id) @@ -203,7 +209,7 @@ internal static class MockupDesktop element.Anchors = AnchorEdges.Left | AnchorEdges.Top; } - private static UiElement? MountChat(DatCollection dats, RenderStack stack) + private static RetailWindowHandle? MountChat(DatCollection dats, RenderStack stack) { var rootInfo = LayoutImporter.ImportInfos(dats, ChatWindowController.LayoutId); if (rootInfo is null) return null; @@ -229,42 +235,42 @@ internal static class MockupDesktop stack.ResolveChrome); if (controller is null) return null; - const int border = RetailChromeSprites.Border; var chatRoot = controller.Root; - float contentW = 490f; - float contentH = chatRoot.Height; - var frame = new UiNineSlicePanel(stack.ResolveChrome) - { - Left = 12f, - Top = 390f, - Width = contentW + 2 * border, - Height = contentH + 2 * border, - MinWidth = 200f, - MinHeight = 90f, - Opacity = 0.75f, - }; - - chatRoot.Left = border; - chatRoot.Top = border; - chatRoot.Width = contentW; - chatRoot.Height = contentH; - chatRoot.Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom; - chatRoot.Draggable = false; - chatRoot.Resizable = false; - frame.AddChild(chatRoot); + RetailWindowHandle handle = RetailWindowFrame.Mount( + stack.UiHost.Root, + chatRoot, + stack.ResolveChrome, + new RetailWindowFrame.Options + { + WindowName = WindowNames.Chat, + Chrome = RetailWindowChrome.NineSlice, + Left = 12f, + Top = 390f, + ContentWidth = 490f, + ContentHeight = chatRoot.Height, + RebaseContentLayout = true, + DatConstraintSource = controller.DatWindowInfo, + MinWidth = 200f, + ResizeX = true, + ResizeY = true, + Opacity = 0.75f, + }); + controller.AttachWindow(handle); stack.UiHost.Root.DefaultTextInput = controller.Input; - return frame; + return handle; } - private static void RegisterWindow(RenderStack stack, List windows, - string name, string title, UiElement element, int zOrder) + private static void RecordWindow( + List windows, + string title, + RetailWindowHandle handle, + int zOrder) { + UiElement element = handle.OuterFrame; element.ZOrder = zOrder; - stack.UiHost.Root.AddChild(element); - stack.UiHost.RegisterWindow(name, element); windows.Add(new MockupWindow( - name, + handle.Name, title, element, new MockupRect(element.Left, element.Top, element.Width, element.Height), diff --git a/src/AcDream.App/UI/Layout/CharacterStatController.cs b/src/AcDream.App/UI/Layout/CharacterStatController.cs index c588e2bf..f73e3042 100644 --- a/src/AcDream.App/UI/Layout/CharacterStatController.cs +++ b/src/AcDream.App/UI/Layout/CharacterStatController.cs @@ -172,8 +172,8 @@ public static class CharacterStatController private const uint ScrollThumbSprite = 0x06004C63u; private const uint ScrollThumbTop = 0x06004C60u; private const uint ScrollThumbBot = 0x06004C66u; - private const uint ScrollUpSprite = 0x06004C6Cu; - private const uint ScrollDownSprite = 0x06004C69u; + private const uint ScrollUpSprite = 0x06004C69u; + private const uint ScrollDownSprite = 0x06004C6Cu; private enum CharacterStatTab { @@ -634,7 +634,11 @@ public static class CharacterStatController if (source is UiScrollbar existingBar) { - ConfigureSkillScrollbar(existingBar, spriteResolve); + existingBar.SpriteResolve ??= id => + { + var (handle, width, height) = spriteResolve(id); + return (handle, width, height); + }; existingBar.Visible = false; return existingBar; } diff --git a/src/AcDream.App/UI/Layout/ChatWindowController.cs b/src/AcDream.App/UI/Layout/ChatWindowController.cs index adba08e1..dabe602d 100644 --- a/src/AcDream.App/UI/Layout/ChatWindowController.cs +++ b/src/AcDream.App/UI/Layout/ChatWindowController.cs @@ -40,14 +40,6 @@ public sealed class ChatWindowController private const uint SendId = 0x10000019u; private const uint MaxMinId = 0x1000046Fu; - // Scrollbar sprite ids from base layout 0x2100003E (confirmed in Task D). - private const uint TrackSprite = 0x06004C5Fu; - private const uint ThumbSprite = 0x06004C63u; // 3-slice middle tile - private const uint ThumbTopSprite = 0x06004C60u; // 3-slice top cap - private const uint ThumbBotSprite = 0x06004C66u; // 3-slice bottom cap - private const uint UpSprite = 0x06004C6Cu; // up arrow (top button) - private const uint DownSprite = 0x06004C69u; // down arrow (bottom button) - // Channel menu sprite ids (confirmed in chat element dump). private const uint MenuNormal = 0x06004D65u; // button face private const uint MenuPressed = 0x06004D66u; // button pressed @@ -72,6 +64,12 @@ public sealed class ChatWindowController /// Channel-selector menu widget. public UiMenu Menu { get; private set; } = null!; + /// Resolved gmMainChatUI root metadata, including DAT size constraints. + public ElementInfo DatWindowInfo { get; private set; } = null!; + + public RetailWindowHandle? WindowHandle { get; private set; } + public bool IsMaximized => _maximized; + // ── Private state ────────────────────────────────────────────────────── private ChatChannelKind _activeChannel = ChatChannelKind.Say; @@ -121,6 +119,7 @@ public sealed class ChatWindowController /// Window top before maximize. private float _normalTop; private bool _maximized; + private UiButton? _maxMinButton; // ── Factory ──────────────────────────────────────────────────────────── @@ -181,7 +180,11 @@ public sealed class ChatWindowController // GameWindow adds this to the host, which re-parents it out of the synthetic wrapper, // orphaning the strays so they never draw. var window = layout.FindElement(RootId) ?? layout.Root; - var c = new ChatWindowController { Root = window }; + var c = new ChatWindowController + { + Root = window, + DatWindowInfo = FindInfo(rootInfo, RootId) ?? rootInfo, + }; // Drop the dat top resize bar (0x1000000F): it is authored 800px wide and // juts out of the content-width window. The host wraps this content in the @@ -234,13 +237,7 @@ public sealed class ChatWindowController bar.Height = bar.Height + oldTop; bar.ResetAnchorCapture(); bar.Model = c.Transcript.Scroll; - bar.SpriteResolve = resolve; - bar.TrackSprite = TrackSprite; - bar.ThumbSprite = ThumbSprite; - bar.ThumbTopSprite = ThumbTopSprite; - bar.ThumbBotSprite = ThumbBotSprite; - bar.UpSprite = UpSprite; - bar.DownSprite = DownSprite; + bar.SpriteResolve ??= resolve; c.Scrollbar = bar; } @@ -301,7 +298,7 @@ public sealed class ChatWindowController ReflowInputRow(); } - // ── Max/min toggle — simplified gmMainChatUI::HandleMaximizeButton ── + // ── Max/min toggle — gmMainChatUI::HandleMaximizeButton ── if (layout.FindElement(MaxMinId) is UiButton maxMinEl) { // The dat puts max/min and the scrollbar up-button at the SAME X (both @@ -310,6 +307,7 @@ public sealed class ChatWindowController if (track is not null) maxMinEl.Left = track.Left - maxMinEl.Width; maxMinEl.ResetAnchorCapture(); + c._maxMinButton = maxMinEl; maxMinEl.OnClick = c.ToggleMaximize; } @@ -319,28 +317,76 @@ public sealed class ChatWindowController // ── Max/min implementation ───────────────────────────────────────────── /// - /// Toggle between the normal chat window height and an expanded 320px height. - /// Simplified port of retail gmMainChatUI::HandleMaximizeButton @0x4cddb0: - /// retail stores the pre-maximize height and restores it on a second click. - /// The 320px expanded size is the approximate retail maximized chat height. + /// Attach the typed outer-frame handle after the controller's imported content + /// has been mounted. Maximize/restore must resize this frame, not the child root. + /// + public void AttachWindow(RetailWindowHandle handle) + { + ArgumentNullException.ThrowIfNull(handle); + if (!ReferenceEquals(handle.ContentRoot, Root)) + throw new ArgumentException("Chat handle content root does not match the bound layout.", nameof(handle)); + if (WindowHandle is not null && !ReferenceEquals(WindowHandle, handle)) + throw new InvalidOperationException("Chat controller is already attached to another window."); + WindowHandle = handle; + } + + /// + /// Exact control flow from gmMainChatUI::HandleMaximizeButton @0x004CCE50: + /// save/restore Y+height, expand by half the parent, choose up/down growth from + /// available space, clamp through the mounted frame's DAT constraints, and set + /// the imported max/min button state. /// private void ToggleMaximize() { - if (!_maximized) + if (WindowHandle is not { IsRegistered: true } handle) + return; + + UiElement frame = handle.OuterFrame; + float parentHeight = frame.Parent?.Height ?? 0f; + if (parentHeight <= 0f) + return; + + if (_maximized) { - _normalHeight = Root.Height; - _normalTop = Root.Top; - // Expand upward: move the top edge up so the bottom stays anchored. - Root.Top = MathF.Max(0f, Root.Top + Root.Height - 320f); - Root.Height = 320f; - _maximized = true; + float restoredHeight = Math.Clamp(_normalHeight, frame.MinHeight, frame.MaxHeight); + float restoredTop = Math.Clamp( + _normalTop, + 0f, + MathF.Max(0f, parentHeight - frame.MinHeight)); + _maximized = false; + _maxMinButton?.TrySetRetailState(RetailUiStateIds.Minimized); + handle.ResizeTo(frame.Width, restoredHeight); + handle.MoveTo(frame.Left, restoredTop); + return; } - else + + _normalTop = frame.Top; + _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); + + targetHeight = MathF.Min(targetHeight, parentHeight - targetTop); + targetHeight = Math.Clamp(targetHeight, frame.MinHeight, frame.MaxHeight); + + _maximized = true; + _maxMinButton?.TrySetRetailState(RetailUiStateIds.Maximized); + handle.ResizeTo(frame.Width, targetHeight); + handle.MoveTo(frame.Left, targetTop); + } + + private static ElementInfo? FindInfo(ElementInfo node, uint id) + { + if (node.Id == id) return node; + foreach (ElementInfo child in node.Children) { - Root.Top = _normalTop; - Root.Height = _normalHeight; - _maximized = false; + ElementInfo? found = FindInfo(child, id); + if (found is not null) return found; } + return null; } // ── Helpers ──────────────────────────────────────────────────────────── diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs index e0bda5ff..ac53d6d5 100644 --- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs +++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs @@ -79,7 +79,7 @@ public static class DatWidgetFactory 6 => new UiMenu(), // UIElement_Menu (reg :120163) 7 => BuildMeter(info, resolve, elementFont), // UIElement_Meter 0xD => new UiViewport(), // UIElement_Viewport — 3-D mini-scene blit leaf - 11 => new UiScrollbar(), // UIElement_Scrollbar (reg :124137) + 11 => BuildScrollbar(info, resolve), // UIElement_Scrollbar (reg :124137) 12 => BuildText(info, resolve, elementFont), // UIElement_Text (reg :115655) 0x10000031u => new UiItemList(resolve), // UIElement_ItemList — toolbar/inventory/paperdoll slots _ => new UiDatElement(info, resolve), // generic fallback (incl. Type 3 chrome/containers) @@ -129,6 +129,68 @@ public static class DatWidgetFactory return e; } + /// + /// Bind inherited scrollbar media structurally. Property 0x77 names the + /// increment button and 0x78 the decrement button; the remaining Type-1 + /// child is the thumb with ordered top/middle/bottom image slices. + /// + private static UiScrollbar BuildScrollbar( + ElementInfo info, + Func resolve) + { + var bar = new UiScrollbar + { + SpriteResolve = resolve, + TrackSprite = DefaultImage(info), + }; + + uint incrementId = ReferencedElementId(info, 0x77u); + uint decrementId = ReferencedElementId(info, 0x78u); + ElementInfo? increment = info.Children.FirstOrDefault(child => child.Id == incrementId); + ElementInfo? decrement = info.Children.FirstOrDefault(child => child.Id == decrementId); + bar.UpSprite = decrement is null ? 0u : DefaultImage(decrement); + bar.DownSprite = increment is null ? 0u : DefaultImage(increment); + + ElementInfo? thumb = info.Children.FirstOrDefault(child => + child.Type == 1u && child.Id != incrementId && child.Id != decrementId); + if (thumb is not null) + { + ElementInfo[] slices = thumb.Children + .Where(child => DefaultImage(child) != 0u) + .OrderBy(child => child.Y) + .ThenBy(child => child.ReadOrder) + .ToArray(); + if (slices.Length > 0) bar.ThumbTopSprite = DefaultImage(slices[0]); + if (slices.Length > 1) bar.ThumbSprite = DefaultImage(slices[1]); + if (slices.Length > 2) bar.ThumbBotSprite = DefaultImage(slices[^1]); + } + + return bar; + } + + private static uint ReferencedElementId(ElementInfo info, uint propertyId) + { + if (!info.TryGetEffectiveProperty(propertyId, out var property)) + return 0u; + return property.Kind switch + { + UiPropertyKind.Enum or UiPropertyKind.DataId => (uint)property.UnsignedValue, + UiPropertyKind.Integer when property.IntegerValue >= 0 => (uint)property.IntegerValue, + _ => 0u, + }; + } + + private static uint DefaultImage(ElementInfo info) + { + uint stateId = info.EffectiveDefaultStateId(); + if (info.States.TryGetValue(stateId, out var state) && state.Image is { } image) + return image.File; + if (info.States.TryGetValue(UiStateInfo.DirectStateId, out var direct) + && direct.Image is { } directImage) + return directImage.File; + return 0u; + } + // ── Meter ──────────────────────────────────────────────────────────────── /// diff --git a/src/AcDream.App/UI/Layout/InventoryController.cs b/src/AcDream.App/UI/Layout/InventoryController.cs index 3366e9e4..a61a56f4 100644 --- a/src/AcDream.App/UI/Layout/InventoryController.cs +++ b/src/AcDream.App/UI/Layout/InventoryController.cs @@ -25,15 +25,6 @@ public sealed class InventoryController : IItemListDragHandler public const uint TitleTextId = 0x100001D3u; // "Inventory of " public const uint ContentsScrollbarId = 0x100001C7u; // gm3DItemsUI gutter scrollbar (right of the grid) - // Scrollbar chrome from base layout 0x2100003E (shared with the chat scrollbar; see - // ChatWindowController). Both inventory + chat scrollbars inherit this base. - private const uint ScrollTrackSprite = 0x06004C5Fu; - private const uint ScrollThumbSprite = 0x06004C63u; // 3-slice middle tile - private const uint ScrollThumbTop = 0x06004C60u; // 3-slice top cap - private const uint ScrollThumbBot = 0x06004C66u; // 3-slice bottom cap - private const uint ScrollUpSprite = 0x06004C6Cu; // up arrow (top button) - private const uint ScrollDownSprite = 0x06004C69u; // down arrow (bottom button) - // 3D-items grid: 192x96 → 6 cols x 3 rows of the 32x32 UIItem cell (template 0x21000037). private const int ContentsColumns = 6; private const float ContentsCellPx = 32f; // gm3DItemsUI grid (192x96 = 6x3 of 32px) @@ -108,19 +99,13 @@ public sealed class InventoryController : IItemListDragHandler _contentsGrid.CellHeight = ContentsCellPx; } - // Bind the gutter scrollbar to the contents grid's scroll model (the factory built - // 0x100001C7 as a bare Type-11 UiScrollbar; wire it like ChatWindowController does). + // Bind the gutter scrollbar to the contents grid's scroll model. The factory + // already imported its inherited track/thumb/arrow media from LayoutDesc. if (_contentsGrid is not null && layout.FindElement(ContentsScrollbarId) is UiScrollbar bar) { - bar.Model = _contentsGrid.Scroll; - bar.SpriteResolve = _contentsGrid.SpriteResolve; // chrome resolve from the factory ctor - bar.TrackSprite = ScrollTrackSprite; - bar.ThumbSprite = ScrollThumbSprite; - bar.ThumbTopSprite = ScrollThumbTop; - bar.ThumbBotSprite = ScrollThumbBot; - bar.UpSprite = ScrollUpSprite; - bar.DownSprite = ScrollDownSprite; + bar.Model = _contentsGrid.Scroll; + bar.SpriteResolve ??= _contentsGrid.SpriteResolve; } if (_containerList is not null) { diff --git a/src/AcDream.App/UI/Layout/RetailWindowFrame.cs b/src/AcDream.App/UI/Layout/RetailWindowFrame.cs index 0da5ab3d..1240b4ae 100644 --- a/src/AcDream.App/UI/Layout/RetailWindowFrame.cs +++ b/src/AcDream.App/UI/Layout/RetailWindowFrame.cs @@ -2,65 +2,73 @@ using System; namespace AcDream.App.UI.Layout; +/// How a production retained window obtains its outer chrome. +public enum RetailWindowChrome +{ + /// The imported root already is the complete retail outer frame. + Imported, + + /// Wrap imported content in the shared retail nine-slice frame. + NineSlice, + + /// Use the shared frame variant with the toolbar's two height stops. + CollapsibleNineSlice, +} + /// -/// Mounts an imported retail window's content into the shared 8-piece beveled -/// chrome () and optionally registers it with -/// the window manager. This is THE mount recipe every -/// retail window follows: frame sized content + 2×border, content inset by -/// the border with Draggable/Resizable off (the frame owns move/resize). -/// -/// The vitals/chat/toolbar/inventory windows still inline this recipe -/// in GameWindow (pre-extraction); new windows must mount through this -/// helper instead of copying the block again — see -/// docs/research/2026-07-02-ui-architecture-review.md (theme T1). +/// The single mount path for production retained windows. It owns the distinction +/// between DAT roots that already contain chrome and content roots that require the +/// shared nine-slice wrapper, applies exact resize/opacity/visibility policy, mounts +/// the outer frame, and returns its registered typed handle. /// public static class RetailWindowFrame { - /// Per-window placement + behavior knobs. Null-valued knobs keep - /// the widget defaults ( / ). public sealed record Options { - /// Initial window position (screen px). + public required string WindowName { get; init; } + public RetailWindowChrome Chrome { get; init; } = RetailWindowChrome.NineSlice; + + /// Initial outer-frame position in screen pixels. public float Left { get; init; } public float Top { get; init; } - /// Minimum frame size; null = lock to content + 2×border (non-shrinkable). + /// + /// Optional mounted content extent. Used when a LayoutDesc root shares a + /// larger auxiliary coordinate space (the main chat root is cropped to 490px). + /// + public float? ContentWidth { get; init; } + public float? ContentHeight { get; init; } + public bool RebaseContentLayout { get; init; } + + /// + /// Optional DAT element whose effective 0x3C..0x3F properties provide + /// max-height, max-width, min-height, and min-width respectively. Explicit + /// option values win; wrapper chrome is added to DAT content constraints. + /// + public ElementInfo? DatConstraintSource { get; init; } public float? MinWidth { get; init; } public float? MinHeight { get; init; } - - /// Maximum frame height while resizing; null = unbounded. + public float? MaxWidth { get; init; } public float? MaxHeight { get; init; } - /// Allow horizontal resize (frame default: true). + public bool Draggable { get; init; } = true; + public bool Resizable { get; init; } = true; public bool ResizeX { get; init; } = true; + public bool ResizeY { get; init; } = true; + public ResizeEdges ResizableEdges { get; init; } = + ResizeEdges.Left | ResizeEdges.Right | ResizeEdges.Top | ResizeEdges.Bottom; + public bool ConstrainDragToParent { get; init; } - /// Restrict which edges start a resize; null = all edges. - public ResizeEdges? ResizableEdges { get; init; } - - /// Window translucency (retail SetDefaultOpacity analog). public float Opacity { get; init; } = 1f; - - /// Start shown or hidden (hidden windows toggle via the window manager). public bool Visible { get; init; } = true; - /// How the content tracks the frame when it resizes. public AnchorEdges ContentAnchors { get; init; } = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom; - - /// Force the content's ClickThrough; null = leave as imported. public bool? ContentClickThrough { get; init; } - - /// Register the frame under this name in the UiRoot window - /// manager (); null = unmanaged window. - public string? WindowName { get; init; } + public IRetainedPanelController? Controller { get; init; } } - /// - /// Wrap (sized by the importer) in the beveled - /// chrome, add it to , and register it when - /// is set. Returns the frame. - /// - public static UiNineSlicePanel Mount( + public static RetailWindowHandle Mount( UiRoot root, UiElement content, Func resolveChrome, @@ -70,39 +78,103 @@ public static class RetailWindowFrame ArgumentNullException.ThrowIfNull(content); ArgumentNullException.ThrowIfNull(resolveChrome); ArgumentNullException.ThrowIfNull(options); + ArgumentException.ThrowIfNullOrWhiteSpace(options.WindowName); - const int border = RetailChromeSprites.Border; - float contentW = content.Width; - float contentH = content.Height; + float contentWidth = options.ContentWidth ?? content.Width; + float contentHeight = options.ContentHeight ?? content.Height; + bool wrapped = options.Chrome != RetailWindowChrome.Imported; + int inset = wrapped ? 2 * RetailChromeSprites.Border : 0; + float outerWidth = contentWidth + inset; + float outerHeight = contentHeight + inset; - var frame = new UiNineSlicePanel(resolveChrome) + UiElement outerFrame; + if (wrapped) { - Left = options.Left, - Top = options.Top, - Width = contentW + 2 * border, - Height = contentH + 2 * border, - MinWidth = options.MinWidth ?? contentW + 2 * border, - MinHeight = options.MinHeight ?? contentH + 2 * border, - ResizeX = options.ResizeX, - Opacity = options.Opacity, - Visible = options.Visible, - }; - if (options.MaxHeight is { } maxHeight) frame.MaxHeight = maxHeight; - if (options.ResizableEdges is { } edges) frame.ResizableEdges = edges; + outerFrame = options.Chrome switch + { + RetailWindowChrome.NineSlice => new UiNineSlicePanel(resolveChrome), + RetailWindowChrome.CollapsibleNineSlice => new UiCollapsibleFrame(resolveChrome), + _ => throw new ArgumentOutOfRangeException(nameof(options.Chrome)), + }; - content.Left = border; - content.Top = border; - content.Width = contentW; - content.Height = contentH; - content.Anchors = options.ContentAnchors; - if (options.ContentClickThrough is { } clickThrough) content.ClickThrough = clickThrough; - content.Draggable = false; // the frame owns window move/resize - content.Resizable = false; + const int border = RetailChromeSprites.Border; + content.Left = border; + content.Top = border; + content.Width = contentWidth; + content.Height = contentHeight; + content.Anchors = options.ContentAnchors; + content.Draggable = false; + content.Resizable = false; + if (options.ContentClickThrough is { } clickThrough) + content.ClickThrough = clickThrough; + if (options.RebaseContentLayout) + content.RebaseChildLayoutBaselines(); + outerFrame.AddChild(content); + } + else + { + outerFrame = content; + content.Width = contentWidth; + content.Height = contentHeight; + content.Anchors = AnchorEdges.None; + if (options.ContentClickThrough is { } clickThrough) + content.ClickThrough = clickThrough; + if (options.RebaseContentLayout) + content.RebaseChildLayoutBaselines(); + } - frame.AddChild(content); - root.AddChild(frame); - if (options.WindowName is { } name) - root.RegisterWindow(name, frame); - return frame; + outerFrame.Left = options.Left; + outerFrame.Top = options.Top; + outerFrame.Width = outerWidth; + outerFrame.Height = outerHeight; + outerFrame.Anchors = AnchorEdges.None; + outerFrame.Draggable = options.Draggable; + outerFrame.Resizable = options.Resizable; + outerFrame.ResizeX = options.ResizeX; + outerFrame.ResizeY = options.ResizeY; + outerFrame.ResizableEdges = options.ResizableEdges; + outerFrame.ConstrainDragToParent = options.ConstrainDragToParent; + outerFrame.Opacity = Math.Clamp(options.Opacity, 0f, 1f); + outerFrame.Visible = options.Visible; + + outerFrame.MinWidth = ResolveConstraint( + options.MinWidth, options.DatConstraintSource, 0x3Fu, outerWidth, inset); + outerFrame.MinHeight = ResolveConstraint( + options.MinHeight, options.DatConstraintSource, 0x3Eu, outerHeight, inset); + outerFrame.MaxWidth = Math.Max( + outerFrame.MinWidth, + ResolveConstraint(options.MaxWidth, options.DatConstraintSource, 0x3Du, float.MaxValue, inset)); + outerFrame.MaxHeight = Math.Max( + outerFrame.MinHeight, + ResolveConstraint(options.MaxHeight, options.DatConstraintSource, 0x3Cu, float.MaxValue, inset)); + + // Capture the wrapper/content baseline at the mounted design extent now, + // so a resize that occurs before the first draw uses the same margins as a + // resize after rendering one frame. + if (wrapped) + content.ApplyAnchor(outerWidth, outerHeight); + + root.AddChild(outerFrame); + return root.RegisterWindow( + options.WindowName, + outerFrame, + content, + options.Controller); + } + + private static float ResolveConstraint( + float? explicitValue, + ElementInfo? datSource, + uint propertyId, + float fallback, + int chromeInset) + { + if (explicitValue is { } value) + return value; + if (datSource is not null + && datSource.TryGetEffectiveInteger(propertyId, out int datValue) + && datValue >= 0) + return datValue + chromeInset; + return fallback; } } diff --git a/src/AcDream.App/UI/README.md b/src/AcDream.App/UI/README.md index 94f98a9b..839577cf 100644 --- a/src/AcDream.App/UI/README.md +++ b/src/AcDream.App/UI/README.md @@ -38,6 +38,8 @@ 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. +- `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`, and a default `BitmapFont`. Provides `WireMouse` / `WireKeyboard` helpers for Silk.NET plumbing. diff --git a/src/AcDream.App/UI/RetailWindowManager.cs b/src/AcDream.App/UI/RetailWindowManager.cs index c19b644a..3be2aba0 100644 --- a/src/AcDream.App/UI/RetailWindowManager.cs +++ b/src/AcDream.App/UI/RetailWindowManager.cs @@ -138,7 +138,7 @@ public sealed class RetailWindowManager : IDisposable var frame = handle.OuterFrame; if (!frame.ResizeX) width = frame.Width; if (!frame.ResizeY) height = frame.Height; - width = Math.Clamp(width, frame.MinWidth, float.MaxValue); + width = Math.Clamp(width, frame.MinWidth, frame.MaxWidth); height = Math.Clamp(height, frame.MinHeight, frame.MaxHeight); if (frame.Width == width && frame.Height == height) return true; frame.Width = width; diff --git a/src/AcDream.App/UI/UiElement.cs b/src/AcDream.App/UI/UiElement.cs index fc583585..c0c9dc43 100644 --- a/src/AcDream.App/UI/UiElement.cs +++ b/src/AcDream.App/UI/UiElement.cs @@ -219,7 +219,8 @@ public abstract class UiElement public float MinWidth { get; set; } = 40f; public float MinHeight { get; set; } = 40f; - /// Maximum height enforced while resizing (default unbounded). Pairs with MinHeight. + /// Maximum size enforced while resizing (default unbounded). + public float MaxWidth { get; set; } = float.MaxValue; public float MaxHeight { get; set; } = float.MaxValue; /// Allow horizontal (width) resize. Ignored unless . diff --git a/src/AcDream.App/UI/UiRoot.cs b/src/AcDream.App/UI/UiRoot.cs index dc497dc2..3562e5d7 100644 --- a/src/AcDream.App/UI/UiRoot.cs +++ b/src/AcDream.App/UI/UiRoot.cs @@ -344,7 +344,7 @@ public sealed class UiRoot : UiElement _resizeStartX, _resizeStartY, _resizeStartW, _resizeStartH, _resizeEdges, x - _resizeMouseX, y - _resizeMouseY, _resizeTarget.MinWidth, _resizeTarget.MinHeight, - float.MaxValue, _resizeTarget.MaxHeight); + _resizeTarget.MaxWidth, _resizeTarget.MaxHeight); _resizeTarget.Left = nx; _resizeTarget.Top = ny; _resizeTarget.Width = nw; _resizeTarget.Height = nh; return; diff --git a/src/AcDream.App/UI/UiScrollable.cs b/src/AcDream.App/UI/UiScrollable.cs index f9e78a12..cc12eaee 100644 --- a/src/AcDream.App/UI/UiScrollable.cs +++ b/src/AcDream.App/UI/UiScrollable.cs @@ -33,6 +33,25 @@ public sealed class UiScrollable /// True when the offset is at (or past) the bottom — used for bottom-pin. public bool AtEnd => _scrollY >= MaxScroll; + /// + /// Update the content/view extents and clamp the current offset. When + /// is true, a view that was at the end before + /// the geometry change remains at the end afterward. User-driven scrollbar + /// changes move off the end, so later layout passes + /// preserve that selected position instead of snapping it back. + /// + public void SetExtents(int contentHeight, int viewHeight, bool preserveEnd = false) + { + bool wasAtEnd = AtEnd; + ContentHeight = Math.Max(0, contentHeight); + ViewHeight = Math.Max(0, viewHeight); + + if (preserveEnd && wasAtEnd) + ScrollToEnd(); + else + SetScrollY(_scrollY); + } + /// Set the offset, clamped to [0, MaxScroll] (SetScrollableXY clamp). public void SetScrollY(int y) => _scrollY = Math.Clamp(y, 0, MaxScroll); diff --git a/src/AcDream.App/UI/UiScrollbar.cs b/src/AcDream.App/UI/UiScrollbar.cs index d574b597..f1f827e1 100644 --- a/src/AcDream.App/UI/UiScrollbar.cs +++ b/src/AcDream.App/UI/UiScrollbar.cs @@ -91,10 +91,10 @@ public sealed class UiScrollbar : UiElement // sprite (~16×32) repeats to fill the element height instead of stretch-distorting. DrawTiled(ctx, resolve, TrackSprite, 0f, 0f, Width, Height); - // Up button — top ButtonH rows. UpSprite (0x06004C6C) is the up-arrow art, drawn 1:1. + // Decrement/up button — top ButtonH rows (inherited element 0x10000071). DrawSprite(ctx, resolve, UpSprite, 0f, 0f, Width, ButtonH); - // Down button — bottom ButtonH rows. DownSprite (0x06004C69) is the down-arrow art. + // Increment/down button — bottom ButtonH rows (inherited element 0x10000072). DrawSprite(ctx, resolve, DownSprite, 0f, Height - ButtonH, Width, ButtonH); // Thumb — only when content overflows the view. Retail 3-slice: top cap + @@ -129,17 +129,6 @@ public sealed class UiScrollbar : UiElement ctx.DrawSprite(tex, x, y, w, h, 0f, 0f, 1f, 1f, Vector4.One); } - /// Draw a sprite 1:1 but vertically FLIPPED (V0/V1 swapped) — used to point - /// the top scroll button's (down-art) arrow upward. - private void DrawSpriteFlipV(UiRenderContext ctx, Func resolve, - uint id, float x, float y, float w, float h) - { - if (id == 0 || w <= 0f || h <= 0f) return; - var (tex, _, _) = resolve(id); - if (tex == 0) return; - ctx.DrawSprite(tex, x, y, w, h, 0f, 1f, 1f, 0f, Vector4.One); - } - /// Draw a sprite TILED to fill the dest rect (UV-repeat at native size on /// both axes — the UI texture is GL_REPEAT-wrapped). A native-width axis gives 1:1. private void DrawTiled(UiRenderContext ctx, Func resolve, diff --git a/src/AcDream.App/UI/UiText.cs b/src/AcDream.App/UI/UiText.cs index 1da041fe..8885fc22 100644 --- a/src/AcDream.App/UI/UiText.cs +++ b/src/AcDream.App/UI/UiText.cs @@ -145,9 +145,6 @@ public sealed class UiText : UiElement /// The scroll model — also read by the linked UiScrollbar. public UiScrollable Scroll { get; } = new(); - /// True while the view is pinned to the newest line (auto-scrolls as content grows). - private bool _pinBottom = true; - private const float WheelLines = 1f; // lines advanced per wheel notch (retail = 1 line per notch) // ── Cached layout from the last OnDraw, so OnEvent hit-tests the SAME geometry ── @@ -292,9 +289,10 @@ public sealed class UiText : UiElement // Drive the shared scroll model with the current geometry. Scroll.LineHeight = (int)MathF.Round(lh); - Scroll.ContentHeight = (int)MathF.Ceiling(contentH); - Scroll.ViewHeight = (int)MathF.Floor(innerH); - if (_pinBottom) Scroll.ScrollToEnd(); + Scroll.SetExtents( + (int)MathF.Ceiling(contentH), + (int)MathF.Floor(innerH), + preserveEnd: true); // UiScrollable: ScrollY=0 is TOP/oldest, ScrollY=MaxScroll is BOTTOM/newest. // Visual layout: newest at bottom → baseY = bottom - contentH (ScrollY at max). @@ -358,7 +356,6 @@ public sealed class UiText : UiElement // ScrollByLines sign: +down/newer, -up/older. // e.Data0 > 0 → wheel up → want older → ScrollByLines with negative lines. Scroll.ScrollByLines((int)(-e.Data0 * WheelLines)); - _pinBottom = Scroll.AtEnd; return true; } diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs index 45233e60..df6b303c 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs @@ -1511,8 +1511,8 @@ public class CharacterStatControllerTests Assert.NotNull(scrollbar.Model); Assert.NotNull(scrollbar.SpriteResolve); Assert.Equal(0x06004C5Fu, scrollbar.TrackSprite); - Assert.Equal(0x06004C6Cu, scrollbar.UpSprite); - Assert.Equal(0x06004C69u, scrollbar.DownSprite); + Assert.Equal(0x06004C69u, scrollbar.UpSprite); + Assert.Equal(0x06004C6Cu, scrollbar.DownSprite); } // ── Helpers ────────────────────────────────────────────────────────────── diff --git a/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs b/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs index 0dd71605..aa8029d8 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs @@ -64,6 +64,32 @@ public class ChatLayoutConformanceTests Assert.Equal(0x10000016u, input.DatElementId); } + [Fact] + public void ChatFixture_ScrollbarImportsInheritedMediaRoles() + { + var layout = FixtureLoader.LoadChat(); + var scrollbar = Assert.IsType(layout.FindElement(0x10000012u)); + + Assert.Equal(0x06004C5Fu, scrollbar.TrackSprite); + Assert.Equal(0x06004C60u, scrollbar.ThumbTopSprite); + Assert.Equal(0x06004C63u, scrollbar.ThumbSprite); + Assert.Equal(0x06004C66u, scrollbar.ThumbBotSprite); + Assert.Equal(0x06004C69u, scrollbar.UpSprite); + Assert.Equal(0x06004C6Cu, scrollbar.DownSprite); + } + + [Fact] + public void ChatFixture_WindowRootCarriesRetailHeightConstraints() + { + var root = FixtureLoader.LoadChatInfos(); + var window = Find(root, 0x1000000Eu)!; + + Assert.True(window.TryGetEffectiveInteger(0x3Eu, out int minHeight)); + Assert.True(window.TryGetEffectiveInteger(0x3Cu, out int maxHeight)); + Assert.Equal(100, minHeight); + Assert.Equal(360, maxHeight); + } + [Fact] public void ChatFixture_CroppedDockedRoot_ReflowsContentAcrossMountedWidth() { @@ -117,4 +143,121 @@ public class ChatLayoutConformanceTests Assert.Equal(fittedWidth, controller.Menu.Width); Assert.Equal(controller.Menu.Left + fittedWidth, controller.Input.Left); } + + [Fact] + public void ChatMaximize_ResizesOuterFrameByHalfParent_AndRestores() + { + var infos = FixtureLoader.LoadChatInfos(); + var layout = LayoutImporter.Build(infos, NoTex, null); + var controller = ChatWindowController.Bind( + infos, + layout, + new ChatVM(new ChatLog()), + () => NullCommandBus.Instance, + null, + null, + NoTex)!; + var root = new UiRoot { Width = 800, Height = 600 }; + RetailWindowHandle handle = RetailWindowFrame.Mount( + root, + controller.Root, + NoTex, + new RetailWindowFrame.Options + { + WindowName = WindowNames.Chat, + Chrome = RetailWindowChrome.NineSlice, + Left = 12f, + Top = 390f, + ContentWidth = 490f, + ContentHeight = controller.Root.Height, + RebaseContentLayout = true, + DatConstraintSource = controller.DatWindowInfo, + MinWidth = 200f, + }); + controller.AttachWindow(handle); + var maxMin = Assert.IsType(layout.FindElement(0x1000046Fu)); + + maxMin.OnClick!(); + + Assert.True(controller.IsMaximized); + Assert.Equal(90f, handle.Top); + Assert.Equal(370f, handle.Height); // DAT max 360 + 2*5px shared chrome + Assert.Equal(RetailUiStateIds.Maximized, maxMin.ActiveRetailStateId); + controller.Root.ApplyAnchor(handle.Width, handle.Height); + Assert.Equal(360f, controller.Root.Height); + + maxMin.OnClick!(); + + Assert.False(controller.IsMaximized); + Assert.Equal(390f, handle.Top); + Assert.Equal(110f, handle.Height); + Assert.Equal(RetailUiStateIds.Minimized, maxMin.ActiveRetailStateId); + } + + [Fact] + public void MountedChatScrollbar_ButtonsAndThumbDragDriveTranscriptModel() + { + var infos = FixtureLoader.LoadChatInfos(); + var layout = LayoutImporter.Build(infos, NoTex, null); + var controller = ChatWindowController.Bind( + infos, + layout, + new ChatVM(new ChatLog()), + () => NullCommandBus.Instance, + null, + null, + NoTex)!; + var root = new UiRoot { Width = 800, Height = 600 }; + RetailWindowHandle handle = RetailWindowFrame.Mount( + root, + controller.Root, + NoTex, + new RetailWindowFrame.Options + { + WindowName = WindowNames.Chat, + Chrome = RetailWindowChrome.NineSlice, + Left = 10f, + Top = 440f, + ContentWidth = 490f, + ContentHeight = controller.Root.Height, + RebaseContentLayout = true, + DatConstraintSource = controller.DatWindowInfo, + MinWidth = 200f, + }); + controller.AttachWindow(handle); + + UiScrollbar bar = controller.Scrollbar; + UiScrollable scroll = controller.Transcript.Scroll; + scroll.LineHeight = 16; + scroll.SetExtents(contentHeight: 400, viewHeight: 50, preserveEnd: true); + Assert.Equal(350, scroll.ScrollY); + + var screen = bar.ScreenPosition; + int centerX = (int)(screen.X + bar.Width / 2f); + + // Top/decrement button. + int upY = (int)(screen.Y + 8f); + root.OnMouseDown(UiMouseButton.Left, centerX, upY); + root.OnMouseUp(UiMouseButton.Left, centerX, upY); + Assert.Equal(334, scroll.ScrollY); + + // Re-layout must retain the position chosen through the scrollbar. + scroll.SetExtents(contentHeight: 400, viewHeight: 50, preserveEnd: true); + Assert.Equal(334, scroll.ScrollY); + + // Drag the thumb toward the top of the track. + float trackTop = 16f; + float trackLength = bar.Height - 32f; + var (thumbY, thumbHeight) = UiScrollbar.ThumbRect(scroll, trackTop, trackLength); + int dragStartY = (int)(screen.Y + thumbY + thumbHeight / 2f); + int dragEndY = (int)(screen.Y + trackTop + thumbHeight / 2f); + root.OnMouseDown(UiMouseButton.Left, centerX, dragStartY); + root.OnMouseMove(centerX, dragEndY); + root.OnMouseUp(UiMouseButton.Left, centerX, dragEndY); + + Assert.True(scroll.ScrollY < 334); + int draggedPosition = scroll.ScrollY; + scroll.SetExtents(contentHeight: 400, viewHeight: 50, preserveEnd: true); + Assert.Equal(draggedPosition, scroll.ScrollY); + } } diff --git a/tests/AcDream.App.Tests/UI/Layout/RetailWindowFrameTests.cs b/tests/AcDream.App.Tests/UI/Layout/RetailWindowFrameTests.cs new file mode 100644 index 00000000..b3a422f1 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/RetailWindowFrameTests.cs @@ -0,0 +1,126 @@ +using AcDream.App.UI; +using AcDream.App.UI.Layout; + +namespace AcDream.App.Tests.UI.Layout; + +public sealed class RetailWindowFrameTests +{ + private static (uint, int, int) NoTex(uint _) => (0u, 0, 0); + + [Fact] + public void ImportedChrome_MountsRootWithoutASecondFrame() + { + var root = new UiRoot { Width = 800, Height = 600 }; + var content = new UiPanel { Width = 220, Height = 90 }; + + RetailWindowHandle handle = RetailWindowFrame.Mount( + root, + content, + NoTex, + new RetailWindowFrame.Options + { + WindowName = "vitals", + Chrome = RetailWindowChrome.Imported, + Left = 12, + Top = 18, + ResizeX = true, + ResizeY = false, + MinWidth = 40, + ContentClickThrough = false, + }); + + Assert.Same(content, handle.OuterFrame); + Assert.Same(content, handle.ContentRoot); + Assert.Same(root, content.Parent); + Assert.Equal((12f, 18f, 220f, 90f), + (content.Left, content.Top, content.Width, content.Height)); + Assert.True(content.Draggable); + Assert.True(content.Resizable); + Assert.True(content.ResizeX); + Assert.False(content.ResizeY); + Assert.Equal(40f, content.MinWidth); + } + + [Fact] + public void NineSlice_UsesCroppedContentAndDatHeightConstraints() + { + var root = new UiRoot { Width = 800, Height = 600 }; + var content = new UiPanel { Width = 800, Height = 100 }; + var constraints = Constraints(minHeight: 100, maxHeight: 360); + + RetailWindowHandle handle = RetailWindowFrame.Mount( + root, + content, + NoTex, + new RetailWindowFrame.Options + { + WindowName = "chat", + Chrome = RetailWindowChrome.NineSlice, + Left = 10, + Top = 440, + ContentWidth = 490, + DatConstraintSource = constraints, + MinWidth = 200, + Opacity = 0.75f, + }); + + var frame = Assert.IsType(handle.OuterFrame); + Assert.Same(content, handle.ContentRoot); + Assert.Same(frame, content.Parent); + Assert.Equal((500f, 110f), (frame.Width, frame.Height)); + Assert.Equal((5f, 5f, 490f, 100f), + (content.Left, content.Top, content.Width, content.Height)); + Assert.Equal(200f, frame.MinWidth); + Assert.Equal(110f, frame.MinHeight); + Assert.Equal(370f, frame.MaxHeight); + Assert.Equal(0.75f, frame.Opacity); + } + + [Fact] + public void CollapsibleMount_ReturnsToolbarFrameAndIndependentAxes() + { + var root = new UiRoot { Width = 800, Height = 600 }; + var content = new UiPanel { Width = 300, Height = 122 }; + + RetailWindowHandle handle = RetailWindowFrame.Mount( + root, + content, + NoTex, + new RetailWindowFrame.Options + { + WindowName = "toolbar", + Chrome = RetailWindowChrome.CollapsibleNineSlice, + ResizeX = false, + ResizeY = true, + ResizableEdges = ResizeEdges.Bottom, + ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right, + ContentClickThrough = true, + }); + + var frame = Assert.IsType(handle.OuterFrame); + Assert.False(frame.ResizeX); + Assert.True(frame.ResizeY); + Assert.Equal(ResizeEdges.Bottom, frame.ResizableEdges); + Assert.True(content.ClickThrough); + Assert.False(content.Draggable); + Assert.False(content.Resizable); + } + + private static ElementInfo Constraints(int minHeight, int maxHeight) + { + var info = new ElementInfo(); + var direct = new UiStateInfo { Id = UiStateInfo.DirectStateId }; + direct.Properties.Values[0x3Eu] = new UiPropertyValue + { + Kind = UiPropertyKind.Integer, + IntegerValue = minHeight, + }; + direct.Properties.Values[0x3Cu] = new UiPropertyValue + { + Kind = UiPropertyKind.Integer, + IntegerValue = maxHeight, + }; + info.States[UiStateInfo.DirectStateId] = direct; + return info; + } +} diff --git a/tests/AcDream.App.Tests/UI/UiScrollableTests.cs b/tests/AcDream.App.Tests/UI/UiScrollableTests.cs index 27804b1c..f7bb253c 100644 --- a/tests/AcDream.App.Tests/UI/UiScrollableTests.cs +++ b/tests/AcDream.App.Tests/UI/UiScrollableTests.cs @@ -70,4 +70,25 @@ public class UiScrollableTests s.ScrollByPage(1); Assert.Equal(300, s.ScrollY); } + + [Fact] + public void SetExtents_PreservesEndButRetainsUserScrollback() + { + var s = new UiScrollable(); + + s.SetExtents(contentHeight: 400, viewHeight: 100, preserveEnd: true); + Assert.Equal(300, s.ScrollY); + + // A scrollbar/wheel interaction moves off the end. Repeating layout with + // the same extents must not overwrite that user-selected position. + s.ScrollByLines(-1); + Assert.Equal(284, s.ScrollY); + s.SetExtents(contentHeight: 400, viewHeight: 100, preserveEnd: true); + Assert.Equal(284, s.ScrollY); + + // Returning to the end restores auto-pin for newly appended content. + s.ScrollToEnd(); + s.SetExtents(contentHeight: 500, viewHeight: 100, preserveEnd: true); + Assert.Equal(400, s.ScrollY); + } }