feat(ui): unify retained window mounts
This commit is contained in:
parent
6e9e10367f
commit
a8e9503d2e
20 changed files with 823 additions and 366 deletions
|
|
@ -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-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-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-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-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 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-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` |
|
| 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-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-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-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-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-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-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-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-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-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-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-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` |
|
| 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` |
|
||||||
|
|
|
||||||
|
|
@ -241,17 +241,21 @@ text; full suite green; user visual gate.
|
||||||
|
|
||||||
### 2.2 Use one mount path for every production window
|
### 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.
|
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
|
nine-slice wrappers, with independent X/Y resize policy, so migrations cannot
|
||||||
double-frame or double-inset content.
|
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
|
a time. Delete all inline `UiNineSlicePanel` mount recipes from `GameWindow` and
|
||||||
`MockupDesktop`.
|
`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`.
|
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
|
### 2.3 Persist the complete UI layout
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2099,22 +2099,20 @@ public sealed class GameWindow : IDisposable
|
||||||
healthText: () => (_vitalsVm!.HealthCurrent, _vitalsVm.HealthMax) is (uint c, uint m) ? $"{c}/{m}" : "",
|
healthText: () => (_vitalsVm!.HealthCurrent, _vitalsVm.HealthMax) is (uint c, uint m) ? $"{c}/{m}" : "",
|
||||||
staminaText: () => (_vitalsVm!.StaminaCurrent, _vitalsVm.StaminaMax) 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}" : "");
|
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;
|
var vitalsRoot = imported.Root;
|
||||||
vitalsRoot.Left = 10; vitalsRoot.Top = 30;
|
AcDream.App.UI.Layout.RetailWindowFrame.Mount(
|
||||||
vitalsRoot.ClickThrough = false;
|
_uiHost.Root, vitalsRoot, ResolveChrome,
|
||||||
vitalsRoot.Anchors = AcDream.App.UI.AnchorEdges.None;
|
new AcDream.App.UI.Layout.RetailWindowFrame.Options
|
||||||
vitalsRoot.Draggable = true;
|
{
|
||||||
vitalsRoot.Resizable = true;
|
WindowName = AcDream.App.UI.WindowNames.Vitals,
|
||||||
vitalsRoot.ResizeX = true;
|
Chrome = AcDream.App.UI.Layout.RetailWindowChrome.Imported,
|
||||||
vitalsRoot.ResizeY = false;
|
Left = 10f,
|
||||||
vitalsRoot.MinWidth = 40f;
|
Top = 30f,
|
||||||
_uiHost.Root.AddChild(vitalsRoot);
|
ResizeX = true,
|
||||||
|
ResizeY = false,
|
||||||
|
MinWidth = 40f,
|
||||||
|
ContentClickThrough = false,
|
||||||
|
});
|
||||||
Console.WriteLine("[D.2b] retail UI active — vitals window from LayoutDesc importer (0x2100006C).");
|
Console.WriteLine("[D.2b] retail UI active — vitals window from LayoutDesc importer (0x2100006C).");
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
|
@ -2150,16 +2148,20 @@ public sealed class GameWindow : IDisposable
|
||||||
setUiLocked: SetRetailUiLocked,
|
setUiLocked: SetRetailUiLocked,
|
||||||
datFont: vitalsDatFont);
|
datFont: vitalsDatFont);
|
||||||
|
|
||||||
radarRoot.Left = System.Math.Max(0f, _window!.Size.X - radarRoot.Width - 10f);
|
AcDream.App.UI.Layout.RetailWindowFrame.Mount(
|
||||||
radarRoot.Top = 10f;
|
_uiHost.Root, radarRoot, ResolveChrome,
|
||||||
radarRoot.ClickThrough = false;
|
new AcDream.App.UI.Layout.RetailWindowFrame.Options
|
||||||
// User-positioned top-level window: Anchors.None prevents the
|
{
|
||||||
// per-frame anchor pass from undoing drag-handle movement.
|
WindowName = AcDream.App.UI.WindowNames.Radar,
|
||||||
radarRoot.Anchors = AcDream.App.UI.AnchorEdges.None;
|
Chrome = AcDream.App.UI.Layout.RetailWindowChrome.Imported,
|
||||||
radarRoot.Resizable = false;
|
Left = System.Math.Max(0f, _window!.Size.X - radarRoot.Width - 10f),
|
||||||
radarRoot.ConstrainDragToParent = true;
|
Top = 10f,
|
||||||
_uiHost.Root.AddChild(radarRoot);
|
Resizable = false,
|
||||||
_uiHost.RegisterWindow(AcDream.App.UI.WindowNames.Radar, radarRoot);
|
ResizeX = false,
|
||||||
|
ResizeY = false,
|
||||||
|
ConstrainDragToParent = true,
|
||||||
|
ContentClickThrough = false,
|
||||||
|
});
|
||||||
ApplySavedRadarPosition();
|
ApplySavedRadarPosition();
|
||||||
Console.WriteLine("[D.6] retail radar/compass from LayoutDesc 0x21000074.");
|
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-
|
// HEIGHT so the content's child anchors (input-bar-at-bottom, transcript-
|
||||||
// fills) capture correct margins on first layout; resizing the frame reflows
|
// fills) capture correct margins on first layout; resizing the frame reflows
|
||||||
// them correctly from there.
|
// them correctly from there.
|
||||||
const int chatBorder = AcDream.App.UI.RetailChromeSprites.Border;
|
|
||||||
var chatRoot = chatController.Root;
|
var chatRoot = chatController.Root;
|
||||||
float contentW = 490f, contentH = chatRoot.Height; // dat-authored height
|
var chatHandle = AcDream.App.UI.Layout.RetailWindowFrame.Mount(
|
||||||
var chatFrame = new AcDream.App.UI.UiNineSlicePanel(ResolveChrome)
|
_uiHost.Root, chatRoot, ResolveChrome,
|
||||||
|
new AcDream.App.UI.Layout.RetailWindowFrame.Options
|
||||||
{
|
{
|
||||||
Left = 10, Top = 440,
|
WindowName = AcDream.App.UI.WindowNames.Chat,
|
||||||
Width = contentW + 2 * chatBorder, Height = contentH + 2 * chatBorder,
|
Chrome = AcDream.App.UI.Layout.RetailWindowChrome.NineSlice,
|
||||||
MinWidth = 200f, MinHeight = 90f,
|
Left = 10f,
|
||||||
// Retail chat is translucent — fade the window's backgrounds/chrome
|
Top = 440f,
|
||||||
// (text stays opaque). Configurable opacity is a later step; 0.75 reads
|
ContentWidth = 490f,
|
||||||
// as see-through-but-readable. (retail SetDefaultOpacity ~0.5 / active 1.0)
|
ContentHeight = chatRoot.Height,
|
||||||
|
RebaseContentLayout = true,
|
||||||
|
DatConstraintSource = chatController.DatWindowInfo,
|
||||||
|
MinWidth = 200f,
|
||||||
|
ResizeX = true,
|
||||||
|
ResizeY = true,
|
||||||
Opacity = 0.75f,
|
Opacity = 0.75f,
|
||||||
};
|
});
|
||||||
chatRoot.Left = chatBorder; chatRoot.Top = chatBorder;
|
chatController.AttachWindow(chatHandle);
|
||||||
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;
|
|
||||||
// The LayoutDesc root is 800px wide because it shares the display
|
// The LayoutDesc root is 800px wide because it shares the display
|
||||||
// coordinate space with auxiliary/floaty chat elements. The docked
|
// coordinate space with auxiliary/floaty chat elements. The docked
|
||||||
// gmMainChatUI content is the left 490px. Rebase its immediate children
|
// gmMainChatUI content is the left 490px. Rebase its immediate children
|
||||||
// after that intentional crop so subsequent frame resizing grows the
|
// after that intentional crop so subsequent frame resizing grows the
|
||||||
// transcript/input across the whole mounted interior.
|
// 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
|
// Tab / Enter enters "write mode" by focusing this input (retail's chat
|
||||||
// activation); a focused input suppresses character movement (see the
|
// activation); a focused input suppresses character movement (see the
|
||||||
// WantsKeyboard gate in the movement poll).
|
// 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.
|
// thickness on every side, giving an outer window of 310×132.
|
||||||
const int toolbarBorder = AcDream.App.UI.RetailChromeSprites.Border;
|
const int toolbarBorder = AcDream.App.UI.RetailChromeSprites.Border;
|
||||||
float toolbarContentW = 300f, toolbarContentH = toolbarRoot.Height;
|
float toolbarContentW = 300f, toolbarContentH = toolbarRoot.Height;
|
||||||
var toolbarFrame = new AcDream.App.UI.UiCollapsibleFrame(ResolveChrome)
|
var toolbarHandle = AcDream.App.UI.Layout.RetailWindowFrame.Mount(
|
||||||
|
_uiHost.Root, toolbarRoot, ResolveChrome,
|
||||||
|
new AcDream.App.UI.Layout.RetailWindowFrame.Options
|
||||||
{
|
{
|
||||||
Left = 10, Top = 300,
|
WindowName = AcDream.App.UI.WindowNames.Toolbar,
|
||||||
Width = toolbarContentW + 2 * toolbarBorder,
|
Chrome = AcDream.App.UI.Layout.RetailWindowChrome.CollapsibleNineSlice,
|
||||||
Height = toolbarContentH + 2 * toolbarBorder,
|
Left = 10f,
|
||||||
// The toolbar is fully opaque (not translucent like the chat window).
|
Top = 300f,
|
||||||
Opacity = 1.0f,
|
ContentWidth = toolbarContentW,
|
||||||
};
|
ContentHeight = toolbarContentH,
|
||||||
// Content is offset by the border so it sits inside the chrome.
|
Resizable = false,
|
||||||
toolbarRoot.Left = toolbarBorder;
|
ResizeX = false,
|
||||||
toolbarRoot.Top = toolbarBorder;
|
ResizeY = true,
|
||||||
toolbarRoot.Width = toolbarContentW;
|
ResizableEdges = AcDream.App.UI.ResizeEdges.Bottom,
|
||||||
toolbarRoot.Height = toolbarContentH;
|
ContentAnchors = AcDream.App.UI.AnchorEdges.Left
|
||||||
// Anchor content to Left|Top|Right only — drop Bottom so the dat content
|
| AcDream.App.UI.AnchorEdges.Top
|
||||||
// keeps its full height when the frame collapses; row 2 hides via Visible,
|
| AcDream.App.UI.AnchorEdges.Right,
|
||||||
// not reflow. (Width is fixed so the horizontal anchors are inert but harmless.)
|
ContentClickThrough = true,
|
||||||
toolbarRoot.Anchors = AcDream.App.UI.AnchorEdges.Left | AcDream.App.UI.AnchorEdges.Top
|
});
|
||||||
| AcDream.App.UI.AnchorEdges.Right;
|
var toolbarFrame = (AcDream.App.UI.UiCollapsibleFrame)toolbarHandle.OuterFrame;
|
||||||
// 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);
|
|
||||||
|
|
||||||
// Collapse-to-one-row: the frame's bottom edge snaps between a one-row (row 2 hidden)
|
// 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),
|
// 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}).");
|
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).");
|
Console.WriteLine("[D.5.1] retail toolbar window from LayoutDesc importer (0x21000016).");
|
||||||
}
|
}
|
||||||
else Console.WriteLine("[D.5.1] toolbar: LayoutDesc 0x21000016 not found.");
|
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,
|
_uiHost.Root, characterLayout.Root, ResolveChrome,
|
||||||
new AcDream.App.UI.Layout.RetailWindowFrame.Options
|
new AcDream.App.UI.Layout.RetailWindowFrame.Options
|
||||||
{
|
{
|
||||||
|
WindowName = AcDream.App.UI.WindowNames.Character,
|
||||||
|
Chrome = AcDream.App.UI.Layout.RetailWindowChrome.NineSlice,
|
||||||
Left = 540f,
|
Left = 540f,
|
||||||
Top = 18f,
|
Top = 18f,
|
||||||
MaxHeight = 760f,
|
MaxHeight = 760f,
|
||||||
ResizeX = false,
|
ResizeX = false,
|
||||||
|
ResizeY = true,
|
||||||
ResizableEdges = AcDream.App.UI.ResizeEdges.Bottom,
|
ResizableEdges = AcDream.App.UI.ResizeEdges.Bottom,
|
||||||
Visible = false,
|
Visible = false,
|
||||||
ContentAnchors = AcDream.App.UI.AnchorEdges.Left | AcDream.App.UI.AnchorEdges.Top
|
ContentAnchors = AcDream.App.UI.AnchorEdges.Left | AcDream.App.UI.AnchorEdges.Top
|
||||||
| AcDream.App.UI.AnchorEdges.Bottom,
|
| AcDream.App.UI.AnchorEdges.Bottom,
|
||||||
ContentClickThrough = false,
|
ContentClickThrough = false,
|
||||||
WindowName = AcDream.App.UI.WindowNames.Character,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Console.WriteLine("[D.2b-C] retail character window from LayoutDesc importer (0x2100002E).");
|
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
|
// SAME UiNineSlicePanel the vitals/chat/toolbar windows use. The gmInventoryUI
|
||||||
// LayoutDesc (0x21000023) is 300×362; the frame adds one border thickness on
|
// 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.
|
// 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;
|
float invContentW = inventoryRoot.Width, invContentH = inventoryRoot.Height;
|
||||||
var inventoryFrame = new AcDream.App.UI.UiNineSlicePanel(ResolveChrome)
|
var inventoryHandle = AcDream.App.UI.Layout.RetailWindowFrame.Mount(
|
||||||
|
_uiHost.Root, inventoryRoot, ResolveChrome,
|
||||||
|
new AcDream.App.UI.Layout.RetailWindowFrame.Options
|
||||||
{
|
{
|
||||||
Left = inventoryRoot.Left, Top = inventoryRoot.Top, // keep the dat window position
|
WindowName = AcDream.App.UI.WindowNames.Inventory,
|
||||||
Width = invContentW + 2 * invBorder,
|
Chrome = AcDream.App.UI.Layout.RetailWindowChrome.NineSlice,
|
||||||
Height = invContentH + 2 * invBorder,
|
Left = inventoryRoot.Left,
|
||||||
Opacity = 1.0f,
|
Top = inventoryRoot.Top,
|
||||||
Visible = false, // F12 toggles it
|
ContentWidth = invContentW,
|
||||||
Anchors = AcDream.App.UI.AnchorEdges.None, // user-positioned
|
ContentHeight = invContentH,
|
||||||
Draggable = true,
|
Visible = false,
|
||||||
};
|
ResizeX = false,
|
||||||
// Content offset by the border so it sits inside the chrome.
|
ResizeY = true,
|
||||||
inventoryRoot.Left = invBorder; inventoryRoot.Top = invBorder;
|
ResizableEdges = AcDream.App.UI.ResizeEdges.Bottom,
|
||||||
inventoryRoot.Width = invContentW; inventoryRoot.Height = invContentH;
|
MaxHeight = 560f,
|
||||||
// Content fills the frame vertically (Left|Top|Bottom = fixed width + height tracks
|
ContentAnchors = AcDream.App.UI.AnchorEdges.Left
|
||||||
// the frame) so a vertical resize grows the inner content.
|
| AcDream.App.UI.AnchorEdges.Top
|
||||||
inventoryRoot.Anchors = AcDream.App.UI.AnchorEdges.Left | AcDream.App.UI.AnchorEdges.Top
|
| AcDream.App.UI.AnchorEdges.Bottom,
|
||||||
| AcDream.App.UI.AnchorEdges.Bottom;
|
});
|
||||||
inventoryRoot.Draggable = false;
|
var inventoryFrame = (AcDream.App.UI.UiNineSlicePanel)inventoryHandle.OuterFrame;
|
||||||
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;
|
|
||||||
|
|
||||||
// Stretch the contents region with the window: the gm3DItemsUI sub-window + its grid
|
// Stretch the contents region with the window: the gm3DItemsUI sub-window + its grid
|
||||||
// + scrollbar + the full-window backdrop grow vertically (Left|Top|Bottom); the
|
// + 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(0x100001CDu); // paperdoll (fixed at top)
|
||||||
PinTopLeft(0x100001CEu); // side-bag column (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
|
// Phase D.2b-B — populate the inventory from ClientObjectTable: the
|
||||||
// "Contents of Backpack" grid + the pack-selector strip + the burden meter +
|
// "Contents of Backpack" grid + the pack-selector strip + the burden meter +
|
||||||
// captions. Analogue of gmInventoryUI/gmBackpackUI/gm3DItemsUI::PostInit.
|
// captions. Analogue of gmInventoryUI/gmBackpackUI/gm3DItemsUI::PostInit.
|
||||||
|
|
|
||||||
|
|
@ -23,15 +23,15 @@ internal static class MockupDesktop
|
||||||
var windows = new List<MockupWindow>();
|
var windows = new List<MockupWindow>();
|
||||||
|
|
||||||
if (MountVitals(dats, stack, objects) is { } vitals)
|
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)
|
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)
|
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)
|
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)
|
if (MountChat(dats, stack) is { } chat)
|
||||||
RegisterWindow(stack, windows, WindowNames.Chat, "Chat", chat, zOrder: 50);
|
RecordWindow(windows, "Chat", chat, zOrder: 50);
|
||||||
|
|
||||||
MountControls(stack, windows);
|
MountControls(stack, windows);
|
||||||
}
|
}
|
||||||
|
|
@ -40,27 +40,31 @@ internal static class MockupDesktop
|
||||||
=> LayoutImporter.Import(dats, layoutId, stack.ResolveChrome, stack.VitalsDatFont,
|
=> LayoutImporter.Import(dats, layoutId, stack.ResolveChrome, stack.VitalsDatFont,
|
||||||
fontResolve: stack.ResolveDatFont);
|
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);
|
var layout = Import(dats, 0x2100006Cu, stack);
|
||||||
if (layout is null) return null;
|
if (layout is null) return null;
|
||||||
|
|
||||||
FixtureProvider.Populate(0x2100006Cu, layout, stack, objects, dats);
|
FixtureProvider.Populate(0x2100006Cu, layout, stack, objects, dats);
|
||||||
|
|
||||||
var root = layout.Root;
|
return RetailWindowFrame.Mount(
|
||||||
root.Left = 12f;
|
stack.UiHost.Root,
|
||||||
root.Top = 18f;
|
layout.Root,
|
||||||
root.ClickThrough = false;
|
stack.ResolveChrome,
|
||||||
root.Anchors = AnchorEdges.None;
|
new RetailWindowFrame.Options
|
||||||
root.Draggable = true;
|
{
|
||||||
root.Resizable = true;
|
WindowName = WindowNames.Vitals,
|
||||||
root.ResizeX = true;
|
Chrome = RetailWindowChrome.Imported,
|
||||||
root.ResizeY = false;
|
Left = 12f,
|
||||||
root.MinWidth = 40f;
|
Top = 18f,
|
||||||
return root;
|
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);
|
var layout = Import(dats, 0x21000016u, stack);
|
||||||
if (layout is null) return null;
|
if (layout is null) return null;
|
||||||
|
|
@ -71,27 +75,29 @@ internal static class MockupDesktop
|
||||||
var toolbarRoot = layout.Root;
|
var toolbarRoot = layout.Root;
|
||||||
float contentW = toolbarRoot.Width > 0f ? toolbarRoot.Width : 300f;
|
float contentW = toolbarRoot.Width > 0f ? toolbarRoot.Width : 300f;
|
||||||
float contentH = toolbarRoot.Height;
|
float contentH = toolbarRoot.Height;
|
||||||
var frame = new UiCollapsibleFrame(stack.ResolveChrome)
|
RetailWindowHandle handle = RetailWindowFrame.Mount(
|
||||||
|
stack.UiHost.Root,
|
||||||
|
toolbarRoot,
|
||||||
|
stack.ResolveChrome,
|
||||||
|
new RetailWindowFrame.Options
|
||||||
{
|
{
|
||||||
|
WindowName = WindowNames.Toolbar,
|
||||||
|
Chrome = RetailWindowChrome.CollapsibleNineSlice,
|
||||||
Left = 12f,
|
Left = 12f,
|
||||||
Top = 220f,
|
Top = 220f,
|
||||||
Width = contentW + 2 * border,
|
ContentWidth = contentW,
|
||||||
Height = contentH + 2 * border,
|
ContentHeight = contentH,
|
||||||
Opacity = 1f,
|
Resizable = false,
|
||||||
};
|
ResizeX = false,
|
||||||
|
ResizeY = true,
|
||||||
toolbarRoot.Left = border;
|
ResizableEdges = ResizeEdges.Bottom,
|
||||||
toolbarRoot.Top = border;
|
ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right,
|
||||||
toolbarRoot.Width = contentW;
|
ContentClickThrough = true,
|
||||||
toolbarRoot.Height = contentH;
|
});
|
||||||
toolbarRoot.Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right;
|
var frame = (UiCollapsibleFrame)handle.OuterFrame;
|
||||||
toolbarRoot.ClickThrough = true;
|
|
||||||
toolbarRoot.Draggable = false;
|
|
||||||
toolbarRoot.Resizable = false;
|
|
||||||
frame.AddChild(toolbarRoot);
|
|
||||||
|
|
||||||
ConfigureToolbarCollapse(layout, frame, contentH, border);
|
ConfigureToolbarCollapse(layout, frame, contentH, border);
|
||||||
return frame;
|
return handle;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ConfigureToolbarCollapse(ImportedLayout layout, UiCollapsibleFrame frame, float contentH, int border)
|
private static void ConfigureToolbarCollapse(ImportedLayout layout, UiCollapsibleFrame frame, float contentH, int border)
|
||||||
|
|
@ -126,60 +132,60 @@ internal static class MockupDesktop
|
||||||
frame.MaxHeight = expandedH;
|
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);
|
var layout = Import(dats, 0x2100002Eu, stack);
|
||||||
if (layout is null) return null;
|
if (layout is null) return null;
|
||||||
|
|
||||||
FixtureProvider.Populate(0x2100002Eu, layout, stack, objects, dats);
|
FixtureProvider.Populate(0x2100002Eu, layout, stack, objects, dats);
|
||||||
|
|
||||||
var root = layout.Root;
|
return RetailWindowFrame.Mount(
|
||||||
root.Left = 540f;
|
stack.UiHost.Root,
|
||||||
root.Top = 18f;
|
layout.Root,
|
||||||
root.Anchors = AnchorEdges.None;
|
stack.ResolveChrome,
|
||||||
root.ClickThrough = false;
|
new RetailWindowFrame.Options
|
||||||
root.Draggable = true;
|
{
|
||||||
root.Resizable = true;
|
WindowName = WindowNames.Character,
|
||||||
root.MinWidth = root.Width;
|
Chrome = RetailWindowChrome.NineSlice,
|
||||||
root.MinHeight = root.Height;
|
Left = 540f,
|
||||||
return root;
|
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);
|
var layout = Import(dats, 0x21000023u, stack);
|
||||||
if (layout is null) return null;
|
if (layout is null) return null;
|
||||||
|
|
||||||
FixtureProvider.Populate(0x21000023u, layout, stack, objects, dats);
|
FixtureProvider.Populate(0x21000023u, layout, stack, objects, dats);
|
||||||
|
|
||||||
const int border = RetailChromeSprites.Border;
|
|
||||||
var inventoryRoot = layout.Root;
|
var inventoryRoot = layout.Root;
|
||||||
float contentW = inventoryRoot.Width;
|
float contentW = inventoryRoot.Width;
|
||||||
float contentH = inventoryRoot.Height;
|
float contentH = inventoryRoot.Height;
|
||||||
var frame = new UiNineSlicePanel(stack.ResolveChrome)
|
RetailWindowHandle handle = RetailWindowFrame.Mount(
|
||||||
|
stack.UiHost.Root,
|
||||||
|
inventoryRoot,
|
||||||
|
stack.ResolveChrome,
|
||||||
|
new RetailWindowFrame.Options
|
||||||
{
|
{
|
||||||
|
WindowName = WindowNames.Inventory,
|
||||||
|
Chrome = RetailWindowChrome.NineSlice,
|
||||||
Left = 900f,
|
Left = 900f,
|
||||||
Top = 18f,
|
Top = 18f,
|
||||||
Width = contentW + 2 * border,
|
ContentWidth = contentW,
|
||||||
Height = contentH + 2 * border,
|
ContentHeight = contentH,
|
||||||
Opacity = 1f,
|
|
||||||
Visible = true,
|
|
||||||
Anchors = AnchorEdges.None,
|
|
||||||
Draggable = true,
|
|
||||||
Resizable = true,
|
|
||||||
ResizeX = false,
|
ResizeX = false,
|
||||||
|
ResizeY = true,
|
||||||
ResizableEdges = ResizeEdges.Bottom,
|
ResizableEdges = ResizeEdges.Bottom,
|
||||||
MinHeight = contentH + 2 * border,
|
|
||||||
MaxHeight = 560f,
|
MaxHeight = 560f,
|
||||||
};
|
ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom,
|
||||||
|
});
|
||||||
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);
|
|
||||||
|
|
||||||
StretchV(layout, 0x100001D0u);
|
StretchV(layout, 0x100001D0u);
|
||||||
StretchV(layout, 0x100001CFu);
|
StretchV(layout, 0x100001CFu);
|
||||||
|
|
@ -188,7 +194,7 @@ internal static class MockupDesktop
|
||||||
PinTopLeft(layout, 0x100001CDu);
|
PinTopLeft(layout, 0x100001CDu);
|
||||||
PinTopLeft(layout, 0x100001CEu);
|
PinTopLeft(layout, 0x100001CEu);
|
||||||
|
|
||||||
return frame;
|
return handle;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void StretchV(ImportedLayout layout, uint id)
|
private static void StretchV(ImportedLayout layout, uint id)
|
||||||
|
|
@ -203,7 +209,7 @@ internal static class MockupDesktop
|
||||||
element.Anchors = AnchorEdges.Left | AnchorEdges.Top;
|
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);
|
var rootInfo = LayoutImporter.ImportInfos(dats, ChatWindowController.LayoutId);
|
||||||
if (rootInfo is null) return null;
|
if (rootInfo is null) return null;
|
||||||
|
|
@ -229,42 +235,42 @@ internal static class MockupDesktop
|
||||||
stack.ResolveChrome);
|
stack.ResolveChrome);
|
||||||
if (controller is null) return null;
|
if (controller is null) return null;
|
||||||
|
|
||||||
const int border = RetailChromeSprites.Border;
|
|
||||||
var chatRoot = controller.Root;
|
var chatRoot = controller.Root;
|
||||||
float contentW = 490f;
|
RetailWindowHandle handle = RetailWindowFrame.Mount(
|
||||||
float contentH = chatRoot.Height;
|
stack.UiHost.Root,
|
||||||
var frame = new UiNineSlicePanel(stack.ResolveChrome)
|
chatRoot,
|
||||||
|
stack.ResolveChrome,
|
||||||
|
new RetailWindowFrame.Options
|
||||||
{
|
{
|
||||||
|
WindowName = WindowNames.Chat,
|
||||||
|
Chrome = RetailWindowChrome.NineSlice,
|
||||||
Left = 12f,
|
Left = 12f,
|
||||||
Top = 390f,
|
Top = 390f,
|
||||||
Width = contentW + 2 * border,
|
ContentWidth = 490f,
|
||||||
Height = contentH + 2 * border,
|
ContentHeight = chatRoot.Height,
|
||||||
|
RebaseContentLayout = true,
|
||||||
|
DatConstraintSource = controller.DatWindowInfo,
|
||||||
MinWidth = 200f,
|
MinWidth = 200f,
|
||||||
MinHeight = 90f,
|
ResizeX = true,
|
||||||
|
ResizeY = true,
|
||||||
Opacity = 0.75f,
|
Opacity = 0.75f,
|
||||||
};
|
});
|
||||||
|
controller.AttachWindow(handle);
|
||||||
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);
|
|
||||||
|
|
||||||
stack.UiHost.Root.DefaultTextInput = controller.Input;
|
stack.UiHost.Root.DefaultTextInput = controller.Input;
|
||||||
return frame;
|
return handle;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void RegisterWindow(RenderStack stack, List<MockupWindow> windows,
|
private static void RecordWindow(
|
||||||
string name, string title, UiElement element, int zOrder)
|
List<MockupWindow> windows,
|
||||||
|
string title,
|
||||||
|
RetailWindowHandle handle,
|
||||||
|
int zOrder)
|
||||||
{
|
{
|
||||||
|
UiElement element = handle.OuterFrame;
|
||||||
element.ZOrder = zOrder;
|
element.ZOrder = zOrder;
|
||||||
stack.UiHost.Root.AddChild(element);
|
|
||||||
stack.UiHost.RegisterWindow(name, element);
|
|
||||||
windows.Add(new MockupWindow(
|
windows.Add(new MockupWindow(
|
||||||
name,
|
handle.Name,
|
||||||
title,
|
title,
|
||||||
element,
|
element,
|
||||||
new MockupRect(element.Left, element.Top, element.Width, element.Height),
|
new MockupRect(element.Left, element.Top, element.Width, element.Height),
|
||||||
|
|
|
||||||
|
|
@ -172,8 +172,8 @@ public static class CharacterStatController
|
||||||
private const uint ScrollThumbSprite = 0x06004C63u;
|
private const uint ScrollThumbSprite = 0x06004C63u;
|
||||||
private const uint ScrollThumbTop = 0x06004C60u;
|
private const uint ScrollThumbTop = 0x06004C60u;
|
||||||
private const uint ScrollThumbBot = 0x06004C66u;
|
private const uint ScrollThumbBot = 0x06004C66u;
|
||||||
private const uint ScrollUpSprite = 0x06004C6Cu;
|
private const uint ScrollUpSprite = 0x06004C69u;
|
||||||
private const uint ScrollDownSprite = 0x06004C69u;
|
private const uint ScrollDownSprite = 0x06004C6Cu;
|
||||||
|
|
||||||
private enum CharacterStatTab
|
private enum CharacterStatTab
|
||||||
{
|
{
|
||||||
|
|
@ -634,7 +634,11 @@ public static class CharacterStatController
|
||||||
|
|
||||||
if (source is UiScrollbar existingBar)
|
if (source is UiScrollbar existingBar)
|
||||||
{
|
{
|
||||||
ConfigureSkillScrollbar(existingBar, spriteResolve);
|
existingBar.SpriteResolve ??= id =>
|
||||||
|
{
|
||||||
|
var (handle, width, height) = spriteResolve(id);
|
||||||
|
return (handle, width, height);
|
||||||
|
};
|
||||||
existingBar.Visible = false;
|
existingBar.Visible = false;
|
||||||
return existingBar;
|
return existingBar;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,14 +40,6 @@ public sealed class ChatWindowController
|
||||||
private const uint SendId = 0x10000019u;
|
private const uint SendId = 0x10000019u;
|
||||||
private const uint MaxMinId = 0x1000046Fu;
|
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).
|
// Channel menu sprite ids (confirmed in chat element dump).
|
||||||
private const uint MenuNormal = 0x06004D65u; // button face
|
private const uint MenuNormal = 0x06004D65u; // button face
|
||||||
private const uint MenuPressed = 0x06004D66u; // button pressed
|
private const uint MenuPressed = 0x06004D66u; // button pressed
|
||||||
|
|
@ -72,6 +64,12 @@ public sealed class ChatWindowController
|
||||||
/// <summary>Channel-selector menu widget.</summary>
|
/// <summary>Channel-selector menu widget.</summary>
|
||||||
public UiMenu Menu { get; private set; } = null!;
|
public UiMenu Menu { get; private set; } = null!;
|
||||||
|
|
||||||
|
/// <summary>Resolved gmMainChatUI root metadata, including DAT size constraints.</summary>
|
||||||
|
public ElementInfo DatWindowInfo { get; private set; } = null!;
|
||||||
|
|
||||||
|
public RetailWindowHandle? WindowHandle { get; private set; }
|
||||||
|
public bool IsMaximized => _maximized;
|
||||||
|
|
||||||
// ── Private state ──────────────────────────────────────────────────────
|
// ── Private state ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
private ChatChannelKind _activeChannel = ChatChannelKind.Say;
|
private ChatChannelKind _activeChannel = ChatChannelKind.Say;
|
||||||
|
|
@ -121,6 +119,7 @@ public sealed class ChatWindowController
|
||||||
/// <summary>Window top before maximize.</summary>
|
/// <summary>Window top before maximize.</summary>
|
||||||
private float _normalTop;
|
private float _normalTop;
|
||||||
private bool _maximized;
|
private bool _maximized;
|
||||||
|
private UiButton? _maxMinButton;
|
||||||
|
|
||||||
// ── Factory ────────────────────────────────────────────────────────────
|
// ── Factory ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -181,7 +180,11 @@ public sealed class ChatWindowController
|
||||||
// GameWindow adds this to the host, which re-parents it out of the synthetic wrapper,
|
// GameWindow adds this to the host, which re-parents it out of the synthetic wrapper,
|
||||||
// orphaning the strays so they never draw.
|
// orphaning the strays so they never draw.
|
||||||
var window = layout.FindElement(RootId) ?? layout.Root;
|
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
|
// 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
|
// 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.Height = bar.Height + oldTop;
|
||||||
bar.ResetAnchorCapture();
|
bar.ResetAnchorCapture();
|
||||||
bar.Model = c.Transcript.Scroll;
|
bar.Model = c.Transcript.Scroll;
|
||||||
bar.SpriteResolve = resolve;
|
bar.SpriteResolve ??= resolve;
|
||||||
bar.TrackSprite = TrackSprite;
|
|
||||||
bar.ThumbSprite = ThumbSprite;
|
|
||||||
bar.ThumbTopSprite = ThumbTopSprite;
|
|
||||||
bar.ThumbBotSprite = ThumbBotSprite;
|
|
||||||
bar.UpSprite = UpSprite;
|
|
||||||
bar.DownSprite = DownSprite;
|
|
||||||
c.Scrollbar = bar;
|
c.Scrollbar = bar;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -301,7 +298,7 @@ public sealed class ChatWindowController
|
||||||
ReflowInputRow();
|
ReflowInputRow();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Max/min toggle — simplified gmMainChatUI::HandleMaximizeButton ──
|
// ── Max/min toggle — gmMainChatUI::HandleMaximizeButton ──
|
||||||
if (layout.FindElement(MaxMinId) is UiButton maxMinEl)
|
if (layout.FindElement(MaxMinId) is UiButton maxMinEl)
|
||||||
{
|
{
|
||||||
// The dat puts max/min and the scrollbar up-button at the SAME X (both
|
// 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)
|
if (track is not null)
|
||||||
maxMinEl.Left = track.Left - maxMinEl.Width;
|
maxMinEl.Left = track.Left - maxMinEl.Width;
|
||||||
maxMinEl.ResetAnchorCapture();
|
maxMinEl.ResetAnchorCapture();
|
||||||
|
c._maxMinButton = maxMinEl;
|
||||||
maxMinEl.OnClick = c.ToggleMaximize;
|
maxMinEl.OnClick = c.ToggleMaximize;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -319,28 +317,76 @@ public sealed class ChatWindowController
|
||||||
// ── Max/min implementation ─────────────────────────────────────────────
|
// ── Max/min implementation ─────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Toggle between the normal chat window height and an expanded 320px height.
|
/// Attach the typed outer-frame handle after the controller's imported content
|
||||||
/// Simplified port of retail <c>gmMainChatUI::HandleMaximizeButton @0x4cddb0</c>:
|
/// has been mounted. Maximize/restore must resize this frame, not the child root.
|
||||||
/// retail stores the pre-maximize height and restores it on a second click.
|
/// </summary>
|
||||||
/// The 320px expanded size is the approximate retail maximized chat height.
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exact control flow from <c>gmMainChatUI::HandleMaximizeButton @0x004CCE50</c>:
|
||||||
|
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void ToggleMaximize()
|
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;
|
float restoredHeight = Math.Clamp(_normalHeight, frame.MinHeight, frame.MaxHeight);
|
||||||
_normalTop = Root.Top;
|
float restoredTop = Math.Clamp(
|
||||||
// Expand upward: move the top edge up so the bottom stays anchored.
|
_normalTop,
|
||||||
Root.Top = MathF.Max(0f, Root.Top + Root.Height - 320f);
|
0f,
|
||||||
Root.Height = 320f;
|
MathF.Max(0f, parentHeight - frame.MinHeight));
|
||||||
_maximized = true;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Root.Top = _normalTop;
|
|
||||||
Root.Height = _normalHeight;
|
|
||||||
_maximized = false;
|
_maximized = false;
|
||||||
|
_maxMinButton?.TrySetRetailState(RetailUiStateIds.Minimized);
|
||||||
|
handle.ResizeTo(frame.Width, restoredHeight);
|
||||||
|
handle.MoveTo(frame.Left, restoredTop);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_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)
|
||||||
|
{
|
||||||
|
ElementInfo? found = FindInfo(child, id);
|
||||||
|
if (found is not null) return found;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helpers ────────────────────────────────────────────────────────────
|
// ── Helpers ────────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,7 @@ public static class DatWidgetFactory
|
||||||
6 => new UiMenu(), // UIElement_Menu (reg :120163)
|
6 => new UiMenu(), // UIElement_Menu (reg :120163)
|
||||||
7 => BuildMeter(info, resolve, elementFont), // UIElement_Meter
|
7 => BuildMeter(info, resolve, elementFont), // UIElement_Meter
|
||||||
0xD => new UiViewport(), // UIElement_Viewport — 3-D mini-scene blit leaf
|
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)
|
12 => BuildText(info, resolve, elementFont), // UIElement_Text (reg :115655)
|
||||||
0x10000031u => new UiItemList(resolve), // UIElement_ItemList — toolbar/inventory/paperdoll slots
|
0x10000031u => new UiItemList(resolve), // UIElement_ItemList — toolbar/inventory/paperdoll slots
|
||||||
_ => new UiDatElement(info, resolve), // generic fallback (incl. Type 3 chrome/containers)
|
_ => new UiDatElement(info, resolve), // generic fallback (incl. Type 3 chrome/containers)
|
||||||
|
|
@ -129,6 +129,68 @@ public static class DatWidgetFactory
|
||||||
return e;
|
return e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
private static UiScrollbar BuildScrollbar(
|
||||||
|
ElementInfo info,
|
||||||
|
Func<uint, (uint tex, int w, int h)> 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 ────────────────────────────────────────────────────────────────
|
// ── Meter ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
||||||
|
|
@ -25,15 +25,6 @@ public sealed class InventoryController : IItemListDragHandler
|
||||||
public const uint TitleTextId = 0x100001D3u; // "Inventory of <name>"
|
public const uint TitleTextId = 0x100001D3u; // "Inventory of <name>"
|
||||||
public const uint ContentsScrollbarId = 0x100001C7u; // gm3DItemsUI gutter scrollbar (right of the grid)
|
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).
|
// 3D-items grid: 192x96 → 6 cols x 3 rows of the 32x32 UIItem cell (template 0x21000037).
|
||||||
private const int ContentsColumns = 6;
|
private const int ContentsColumns = 6;
|
||||||
private const float ContentsCellPx = 32f; // gm3DItemsUI grid (192x96 = 6x3 of 32px)
|
private const float ContentsCellPx = 32f; // gm3DItemsUI grid (192x96 = 6x3 of 32px)
|
||||||
|
|
@ -108,19 +99,13 @@ public sealed class InventoryController : IItemListDragHandler
|
||||||
_contentsGrid.CellHeight = ContentsCellPx;
|
_contentsGrid.CellHeight = ContentsCellPx;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bind the gutter scrollbar to the contents grid's scroll model (the factory built
|
// Bind the gutter scrollbar to the contents grid's scroll model. The factory
|
||||||
// 0x100001C7 as a bare Type-11 UiScrollbar; wire it like ChatWindowController does).
|
// already imported its inherited track/thumb/arrow media from LayoutDesc.
|
||||||
if (_contentsGrid is not null
|
if (_contentsGrid is not null
|
||||||
&& layout.FindElement(ContentsScrollbarId) is UiScrollbar bar)
|
&& layout.FindElement(ContentsScrollbarId) is UiScrollbar bar)
|
||||||
{
|
{
|
||||||
bar.Model = _contentsGrid.Scroll;
|
bar.Model = _contentsGrid.Scroll;
|
||||||
bar.SpriteResolve = _contentsGrid.SpriteResolve; // chrome resolve from the factory ctor
|
bar.SpriteResolve ??= _contentsGrid.SpriteResolve;
|
||||||
bar.TrackSprite = ScrollTrackSprite;
|
|
||||||
bar.ThumbSprite = ScrollThumbSprite;
|
|
||||||
bar.ThumbTopSprite = ScrollThumbTop;
|
|
||||||
bar.ThumbBotSprite = ScrollThumbBot;
|
|
||||||
bar.UpSprite = ScrollUpSprite;
|
|
||||||
bar.DownSprite = ScrollDownSprite;
|
|
||||||
}
|
}
|
||||||
if (_containerList is not null)
|
if (_containerList is not null)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -2,65 +2,73 @@ using System;
|
||||||
|
|
||||||
namespace AcDream.App.UI.Layout;
|
namespace AcDream.App.UI.Layout;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>How a production retained window obtains its outer chrome.</summary>
|
||||||
/// Mounts an imported retail window's content into the shared 8-piece beveled
|
public enum RetailWindowChrome
|
||||||
/// chrome (<see cref="UiNineSlicePanel"/>) and optionally registers it with
|
|
||||||
/// the <see cref="UiRoot"/> 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).
|
|
||||||
///
|
|
||||||
/// <para>The vitals/chat/toolbar/inventory windows still inline this recipe
|
|
||||||
/// in <c>GameWindow</c> (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).</para>
|
|
||||||
/// </summary>
|
|
||||||
public static class RetailWindowFrame
|
|
||||||
{
|
{
|
||||||
/// <summary>Per-window placement + behavior knobs. Null-valued knobs keep
|
/// <summary>The imported root already is the complete retail outer frame.</summary>
|
||||||
/// the widget defaults (<see cref="UiElement"/> / <see cref="UiNineSlicePanel"/>).</summary>
|
Imported,
|
||||||
public sealed record Options
|
|
||||||
{
|
|
||||||
/// <summary>Initial window position (screen px).</summary>
|
|
||||||
public float Left { get; init; }
|
|
||||||
public float Top { get; init; }
|
|
||||||
|
|
||||||
/// <summary>Minimum frame size; null = lock to content + 2×border (non-shrinkable).</summary>
|
/// <summary>Wrap imported content in the shared retail nine-slice frame.</summary>
|
||||||
public float? MinWidth { get; init; }
|
NineSlice,
|
||||||
public float? MinHeight { get; init; }
|
|
||||||
|
|
||||||
/// <summary>Maximum frame height while resizing; null = unbounded.</summary>
|
/// <summary>Use the shared frame variant with the toolbar's two height stops.</summary>
|
||||||
public float? MaxHeight { get; init; }
|
CollapsibleNineSlice,
|
||||||
|
|
||||||
/// <summary>Allow horizontal resize (frame default: true).</summary>
|
|
||||||
public bool ResizeX { get; init; } = true;
|
|
||||||
|
|
||||||
/// <summary>Restrict which edges start a resize; null = all edges.</summary>
|
|
||||||
public ResizeEdges? ResizableEdges { get; init; }
|
|
||||||
|
|
||||||
/// <summary>Window translucency (retail SetDefaultOpacity analog).</summary>
|
|
||||||
public float Opacity { get; init; } = 1f;
|
|
||||||
|
|
||||||
/// <summary>Start shown or hidden (hidden windows toggle via the window manager).</summary>
|
|
||||||
public bool Visible { get; init; } = true;
|
|
||||||
|
|
||||||
/// <summary>How the content tracks the frame when it resizes.</summary>
|
|
||||||
public AnchorEdges ContentAnchors { get; init; } =
|
|
||||||
AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom;
|
|
||||||
|
|
||||||
/// <summary>Force the content's ClickThrough; null = leave as imported.</summary>
|
|
||||||
public bool? ContentClickThrough { get; init; }
|
|
||||||
|
|
||||||
/// <summary>Register the frame under this name in the UiRoot window
|
|
||||||
/// manager (<see cref="WindowNames"/>); null = unmanaged window.</summary>
|
|
||||||
public string? WindowName { get; init; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Wrap <paramref name="content"/> (sized by the importer) in the beveled
|
/// The single mount path for production retained windows. It owns the distinction
|
||||||
/// chrome, add it to <paramref name="root"/>, and register it when
|
/// between DAT roots that already contain chrome and content roots that require the
|
||||||
/// <see cref="Options.WindowName"/> is set. Returns the frame.
|
/// shared nine-slice wrapper, applies exact resize/opacity/visibility policy, mounts
|
||||||
|
/// the outer frame, and returns its registered typed handle.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static UiNineSlicePanel Mount(
|
public static class RetailWindowFrame
|
||||||
|
{
|
||||||
|
public sealed record Options
|
||||||
|
{
|
||||||
|
public required string WindowName { get; init; }
|
||||||
|
public RetailWindowChrome Chrome { get; init; } = RetailWindowChrome.NineSlice;
|
||||||
|
|
||||||
|
/// <summary>Initial outer-frame position in screen pixels.</summary>
|
||||||
|
public float Left { get; init; }
|
||||||
|
public float Top { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional mounted content extent. Used when a LayoutDesc root shares a
|
||||||
|
/// larger auxiliary coordinate space (the main chat root is cropped to 490px).
|
||||||
|
/// </summary>
|
||||||
|
public float? ContentWidth { get; init; }
|
||||||
|
public float? ContentHeight { get; init; }
|
||||||
|
public bool RebaseContentLayout { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public ElementInfo? DatConstraintSource { get; init; }
|
||||||
|
public float? MinWidth { get; init; }
|
||||||
|
public float? MinHeight { get; init; }
|
||||||
|
public float? MaxWidth { get; init; }
|
||||||
|
public float? MaxHeight { get; init; }
|
||||||
|
|
||||||
|
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; }
|
||||||
|
|
||||||
|
public float Opacity { get; init; } = 1f;
|
||||||
|
public bool Visible { get; init; } = true;
|
||||||
|
|
||||||
|
public AnchorEdges ContentAnchors { get; init; } =
|
||||||
|
AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom;
|
||||||
|
public bool? ContentClickThrough { get; init; }
|
||||||
|
public IRetainedPanelController? Controller { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static RetailWindowHandle Mount(
|
||||||
UiRoot root,
|
UiRoot root,
|
||||||
UiElement content,
|
UiElement content,
|
||||||
Func<uint, (uint handle, int w, int h)> resolveChrome,
|
Func<uint, (uint handle, int w, int h)> resolveChrome,
|
||||||
|
|
@ -70,39 +78,103 @@ public static class RetailWindowFrame
|
||||||
ArgumentNullException.ThrowIfNull(content);
|
ArgumentNullException.ThrowIfNull(content);
|
||||||
ArgumentNullException.ThrowIfNull(resolveChrome);
|
ArgumentNullException.ThrowIfNull(resolveChrome);
|
||||||
ArgumentNullException.ThrowIfNull(options);
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(options.WindowName);
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
UiElement outerFrame;
|
||||||
|
if (wrapped)
|
||||||
|
{
|
||||||
|
outerFrame = options.Chrome switch
|
||||||
|
{
|
||||||
|
RetailWindowChrome.NineSlice => new UiNineSlicePanel(resolveChrome),
|
||||||
|
RetailWindowChrome.CollapsibleNineSlice => new UiCollapsibleFrame(resolveChrome),
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(options.Chrome)),
|
||||||
|
};
|
||||||
|
|
||||||
const int border = RetailChromeSprites.Border;
|
const int border = RetailChromeSprites.Border;
|
||||||
float contentW = content.Width;
|
|
||||||
float contentH = content.Height;
|
|
||||||
|
|
||||||
var frame = new UiNineSlicePanel(resolveChrome)
|
|
||||||
{
|
|
||||||
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;
|
|
||||||
|
|
||||||
content.Left = border;
|
content.Left = border;
|
||||||
content.Top = border;
|
content.Top = border;
|
||||||
content.Width = contentW;
|
content.Width = contentWidth;
|
||||||
content.Height = contentH;
|
content.Height = contentHeight;
|
||||||
content.Anchors = options.ContentAnchors;
|
content.Anchors = options.ContentAnchors;
|
||||||
if (options.ContentClickThrough is { } clickThrough) content.ClickThrough = clickThrough;
|
content.Draggable = false;
|
||||||
content.Draggable = false; // the frame owns window move/resize
|
|
||||||
content.Resizable = 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);
|
outerFrame.Left = options.Left;
|
||||||
root.AddChild(frame);
|
outerFrame.Top = options.Top;
|
||||||
if (options.WindowName is { } name)
|
outerFrame.Width = outerWidth;
|
||||||
root.RegisterWindow(name, frame);
|
outerFrame.Height = outerHeight;
|
||||||
return frame;
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,8 @@ synthesis + six deep-dive docs under
|
||||||
geometry events, lock propagation, and controller teardown.
|
geometry events, lock propagation, and controller teardown.
|
||||||
- `IRetainedPanelController.cs` — disposable lifecycle contract for
|
- `IRetainedPanelController.cs` — disposable lifecycle contract for
|
||||||
panel-specific show/hide and descendant-focus behavior.
|
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`,
|
- `UiHost.cs` — one-shot wrapper: owns the `UiRoot`, a `TextRenderer`,
|
||||||
and a default `BitmapFont`. Provides `WireMouse` / `WireKeyboard`
|
and a default `BitmapFont`. Provides `WireMouse` / `WireKeyboard`
|
||||||
helpers for Silk.NET plumbing.
|
helpers for Silk.NET plumbing.
|
||||||
|
|
|
||||||
|
|
@ -138,7 +138,7 @@ public sealed class RetailWindowManager : IDisposable
|
||||||
var frame = handle.OuterFrame;
|
var frame = handle.OuterFrame;
|
||||||
if (!frame.ResizeX) width = frame.Width;
|
if (!frame.ResizeX) width = frame.Width;
|
||||||
if (!frame.ResizeY) height = frame.Height;
|
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);
|
height = Math.Clamp(height, frame.MinHeight, frame.MaxHeight);
|
||||||
if (frame.Width == width && frame.Height == height) return true;
|
if (frame.Width == width && frame.Height == height) return true;
|
||||||
frame.Width = width;
|
frame.Width = width;
|
||||||
|
|
|
||||||
|
|
@ -219,7 +219,8 @@ public abstract class UiElement
|
||||||
public float MinWidth { get; set; } = 40f;
|
public float MinWidth { get; set; } = 40f;
|
||||||
public float MinHeight { get; set; } = 40f;
|
public float MinHeight { get; set; } = 40f;
|
||||||
|
|
||||||
/// <summary>Maximum height enforced while resizing (default unbounded). Pairs with MinHeight.</summary>
|
/// <summary>Maximum size enforced while resizing (default unbounded).</summary>
|
||||||
|
public float MaxWidth { get; set; } = float.MaxValue;
|
||||||
public float MaxHeight { get; set; } = float.MaxValue;
|
public float MaxHeight { get; set; } = float.MaxValue;
|
||||||
|
|
||||||
/// <summary>Allow horizontal (width) resize. Ignored unless <see cref="Resizable"/>.</summary>
|
/// <summary>Allow horizontal (width) resize. Ignored unless <see cref="Resizable"/>.</summary>
|
||||||
|
|
|
||||||
|
|
@ -344,7 +344,7 @@ public sealed class UiRoot : UiElement
|
||||||
_resizeStartX, _resizeStartY, _resizeStartW, _resizeStartH,
|
_resizeStartX, _resizeStartY, _resizeStartW, _resizeStartH,
|
||||||
_resizeEdges, x - _resizeMouseX, y - _resizeMouseY,
|
_resizeEdges, x - _resizeMouseX, y - _resizeMouseY,
|
||||||
_resizeTarget.MinWidth, _resizeTarget.MinHeight,
|
_resizeTarget.MinWidth, _resizeTarget.MinHeight,
|
||||||
float.MaxValue, _resizeTarget.MaxHeight);
|
_resizeTarget.MaxWidth, _resizeTarget.MaxHeight);
|
||||||
_resizeTarget.Left = nx; _resizeTarget.Top = ny;
|
_resizeTarget.Left = nx; _resizeTarget.Top = ny;
|
||||||
_resizeTarget.Width = nw; _resizeTarget.Height = nh;
|
_resizeTarget.Width = nw; _resizeTarget.Height = nh;
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,25 @@ public sealed class UiScrollable
|
||||||
/// <summary>True when the offset is at (or past) the bottom — used for bottom-pin.</summary>
|
/// <summary>True when the offset is at (or past) the bottom — used for bottom-pin.</summary>
|
||||||
public bool AtEnd => _scrollY >= MaxScroll;
|
public bool AtEnd => _scrollY >= MaxScroll;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Update the content/view extents and clamp the current offset. When
|
||||||
|
/// <paramref name="preserveEnd"/> is true, a view that was at the end before
|
||||||
|
/// the geometry change remains at the end afterward. User-driven scrollbar
|
||||||
|
/// changes move <see cref="ScrollY"/> off the end, so later layout passes
|
||||||
|
/// preserve that selected position instead of snapping it back.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Set the offset, clamped to [0, MaxScroll] (SetScrollableXY clamp).</summary>
|
/// <summary>Set the offset, clamped to [0, MaxScroll] (SetScrollableXY clamp).</summary>
|
||||||
public void SetScrollY(int y) => _scrollY = Math.Clamp(y, 0, MaxScroll);
|
public void SetScrollY(int y) => _scrollY = Math.Clamp(y, 0, MaxScroll);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -91,10 +91,10 @@ public sealed class UiScrollbar : UiElement
|
||||||
// sprite (~16×32) repeats to fill the element height instead of stretch-distorting.
|
// sprite (~16×32) repeats to fill the element height instead of stretch-distorting.
|
||||||
DrawTiled(ctx, resolve, TrackSprite, 0f, 0f, Width, Height);
|
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);
|
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);
|
DrawSprite(ctx, resolve, DownSprite, 0f, Height - ButtonH, Width, ButtonH);
|
||||||
|
|
||||||
// Thumb — only when content overflows the view. Retail 3-slice: top cap +
|
// 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);
|
ctx.DrawSprite(tex, x, y, w, h, 0f, 0f, 1f, 1f, Vector4.One);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Draw a sprite 1:1 but vertically FLIPPED (V0/V1 swapped) — used to point
|
|
||||||
/// the top scroll button's (down-art) arrow upward.</summary>
|
|
||||||
private void DrawSpriteFlipV(UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Draw a sprite TILED to fill the dest rect (UV-repeat at native size on
|
/// <summary>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.</summary>
|
/// both axes — the UI texture is GL_REPEAT-wrapped). A native-width axis gives 1:1.</summary>
|
||||||
private void DrawTiled(UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve,
|
private void DrawTiled(UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve,
|
||||||
|
|
|
||||||
|
|
@ -145,9 +145,6 @@ public sealed class UiText : UiElement
|
||||||
/// <summary>The scroll model — also read by the linked UiScrollbar.</summary>
|
/// <summary>The scroll model — also read by the linked UiScrollbar.</summary>
|
||||||
public UiScrollable Scroll { get; } = new();
|
public UiScrollable Scroll { get; } = new();
|
||||||
|
|
||||||
/// <summary>True while the view is pinned to the newest line (auto-scrolls as content grows).</summary>
|
|
||||||
private bool _pinBottom = true;
|
|
||||||
|
|
||||||
private const float WheelLines = 1f; // lines advanced per wheel notch (retail = 1 line per notch)
|
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 ──
|
// ── 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.
|
// Drive the shared scroll model with the current geometry.
|
||||||
Scroll.LineHeight = (int)MathF.Round(lh);
|
Scroll.LineHeight = (int)MathF.Round(lh);
|
||||||
Scroll.ContentHeight = (int)MathF.Ceiling(contentH);
|
Scroll.SetExtents(
|
||||||
Scroll.ViewHeight = (int)MathF.Floor(innerH);
|
(int)MathF.Ceiling(contentH),
|
||||||
if (_pinBottom) Scroll.ScrollToEnd();
|
(int)MathF.Floor(innerH),
|
||||||
|
preserveEnd: true);
|
||||||
|
|
||||||
// UiScrollable: ScrollY=0 is TOP/oldest, ScrollY=MaxScroll is BOTTOM/newest.
|
// UiScrollable: ScrollY=0 is TOP/oldest, ScrollY=MaxScroll is BOTTOM/newest.
|
||||||
// Visual layout: newest at bottom → baseY = bottom - contentH (ScrollY at max).
|
// 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.
|
// ScrollByLines sign: +down/newer, -up/older.
|
||||||
// e.Data0 > 0 → wheel up → want older → ScrollByLines with negative lines.
|
// e.Data0 > 0 → wheel up → want older → ScrollByLines with negative lines.
|
||||||
Scroll.ScrollByLines((int)(-e.Data0 * WheelLines));
|
Scroll.ScrollByLines((int)(-e.Data0 * WheelLines));
|
||||||
_pinBottom = Scroll.AtEnd;
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1511,8 +1511,8 @@ public class CharacterStatControllerTests
|
||||||
Assert.NotNull(scrollbar.Model);
|
Assert.NotNull(scrollbar.Model);
|
||||||
Assert.NotNull(scrollbar.SpriteResolve);
|
Assert.NotNull(scrollbar.SpriteResolve);
|
||||||
Assert.Equal(0x06004C5Fu, scrollbar.TrackSprite);
|
Assert.Equal(0x06004C5Fu, scrollbar.TrackSprite);
|
||||||
Assert.Equal(0x06004C6Cu, scrollbar.UpSprite);
|
Assert.Equal(0x06004C69u, scrollbar.UpSprite);
|
||||||
Assert.Equal(0x06004C69u, scrollbar.DownSprite);
|
Assert.Equal(0x06004C6Cu, scrollbar.DownSprite);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,32 @@ public class ChatLayoutConformanceTests
|
||||||
Assert.Equal(0x10000016u, input.DatElementId);
|
Assert.Equal(0x10000016u, input.DatElementId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ChatFixture_ScrollbarImportsInheritedMediaRoles()
|
||||||
|
{
|
||||||
|
var layout = FixtureLoader.LoadChat();
|
||||||
|
var scrollbar = Assert.IsType<UiScrollbar>(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]
|
[Fact]
|
||||||
public void ChatFixture_CroppedDockedRoot_ReflowsContentAcrossMountedWidth()
|
public void ChatFixture_CroppedDockedRoot_ReflowsContentAcrossMountedWidth()
|
||||||
{
|
{
|
||||||
|
|
@ -117,4 +143,121 @@ public class ChatLayoutConformanceTests
|
||||||
Assert.Equal(fittedWidth, controller.Menu.Width);
|
Assert.Equal(fittedWidth, controller.Menu.Width);
|
||||||
Assert.Equal(controller.Menu.Left + fittedWidth, controller.Input.Left);
|
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<UiButton>(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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
126
tests/AcDream.App.Tests/UI/Layout/RetailWindowFrameTests.cs
Normal file
126
tests/AcDream.App.Tests/UI/Layout/RetailWindowFrameTests.cs
Normal file
|
|
@ -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<UiNineSlicePanel>(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<UiCollapsibleFrame>(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -70,4 +70,25 @@ public class UiScrollableTests
|
||||||
s.ScrollByPage(1);
|
s.ScrollByPage(1);
|
||||||
Assert.Equal(300, s.ScrollY);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue