fix(ui): port retail window control feedback
Map synthetic move and resize affordances to the exact DAT cursors, make chat top chrome movable, and replace stale primary-panel height caps with a dynamic screen-edge constraint. This keeps the retained wrapper adaptation aligned with retail Dragbar/Resizebar behavior.
This commit is contained in:
parent
3f3cfdac30
commit
06016014bc
16 changed files with 578 additions and 27 deletions
|
|
@ -44,6 +44,81 @@ Copy this block when adding a new issue:
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## #223 — Primary panel resize stopped before the screen edge
|
||||||
|
|
||||||
|
**Status:** DONE — 2026-07-17 (connected visual gate accepted)
|
||||||
|
**Severity:** MEDIUM
|
||||||
|
**Filed:** 2026-07-17
|
||||||
|
**Component:** retained UI / shared main panel / resize constraints
|
||||||
|
|
||||||
|
**Description:** The Inventory, Character/Skills, and Spellbook/Components
|
||||||
|
panel could not be extended vertically to the bottom of the screen. Magic was
|
||||||
|
not resizable at all, Character carried a fixed 760-pixel maximum, and
|
||||||
|
Inventory calculated one maximum from its startup position that became stale
|
||||||
|
after the shared panel moved.
|
||||||
|
|
||||||
|
**Root cause / status:** Retail LayoutDesc `0x2100006E` owns one 310x372
|
||||||
|
`gmPanelUI` host. Its primary children are all authored into the same 300x362
|
||||||
|
content region with bottom anchoring, while the host exposes a bottom Type-9
|
||||||
|
vertical Resizebar. acdream imports those children as separate wrappers, so
|
||||||
|
their mount policies must preserve that one host contract. All three primary
|
||||||
|
wrappers now expose bottom-only vertical resizing and have no ad-hoc maximum.
|
||||||
|
The shared mount seam carries a dynamic parent-extent constraint, so pointer
|
||||||
|
and restored/programmatic sizes use the panel's current position and stop at
|
||||||
|
the current screen edge.
|
||||||
|
|
||||||
|
**Files:** `src/AcDream.App/UI/RetailUiRuntime.cs`;
|
||||||
|
`src/AcDream.App/UI/Layout/RetailWindowFrame.cs`;
|
||||||
|
`src/AcDream.App/UI/UiElement.cs`; `src/AcDream.App/UI/UiRoot.cs`;
|
||||||
|
`src/AcDream.App/UI/RetailWindowManager.cs`.
|
||||||
|
|
||||||
|
**Research:**
|
||||||
|
`docs/research/2026-07-17-retail-shared-main-panel-pseudocode.md`.
|
||||||
|
|
||||||
|
**Acceptance:** Move the shared primary panel to more than one vertical
|
||||||
|
position. In Inventory, Character/Skills, and Spellbook/Components, drag the
|
||||||
|
bottom resize edge to the bottom of the client. Every panel must reach that
|
||||||
|
edge without crossing it, and its anchored content must use the added height.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #222 — Window move and resize cursors were missing
|
||||||
|
|
||||||
|
**Status:** DONE — 2026-07-17 (connected visual gate accepted)
|
||||||
|
**Severity:** MEDIUM
|
||||||
|
**Filed:** 2026-07-17
|
||||||
|
**Component:** retained UI / cursor feedback / window controls
|
||||||
|
|
||||||
|
**Description:** Hovering or operating the retained-window move and resize
|
||||||
|
regions left the ordinary retail pointer visible. Retail shows a four-way move
|
||||||
|
cursor and direction-specific horizontal, vertical, and diagonal resize
|
||||||
|
cursors.
|
||||||
|
|
||||||
|
**Root cause / status:** `UiRoot` and `CursorFeedbackController` already
|
||||||
|
identified the exact active/hovered window-control intent, but
|
||||||
|
`RetailCursorManager` consumes cursor media rather than the semantic kind. No
|
||||||
|
media was attached to the semantic result for synthetic wrapper edges or the
|
||||||
|
IA-12 whole-window drag region. `RetailCursorCatalog` now maps those five
|
||||||
|
intents to the exact direct DAT cursor surfaces and `(16,16)` hotspots authored
|
||||||
|
on retail Type-2 Dragbar and Type-9 Resizebar controls. Authored widget media
|
||||||
|
keeps retail captured/hovered precedence; an active synthetic control holds its
|
||||||
|
cursor until release.
|
||||||
|
|
||||||
|
**Files:** `src/AcDream.App/UI/CursorFeedbackController.cs`;
|
||||||
|
`src/AcDream.App/UI/RetailCursorCatalog.cs`;
|
||||||
|
`tests/AcDream.App.Tests/UI/CursorFeedbackControllerTests.cs`;
|
||||||
|
`tests/AcDream.App.Tests/UI/RetailCursorCatalogTests.cs`.
|
||||||
|
|
||||||
|
**Research:**
|
||||||
|
`docs/research/2026-07-17-retail-window-control-cursor-pseudocode.md`.
|
||||||
|
|
||||||
|
**Acceptance:** Hover every enabled window edge/corner and an empty move
|
||||||
|
region; the correct retail cursor must appear before pressing and remain stable
|
||||||
|
while dragging outside the original control. Locked or non-resizable windows
|
||||||
|
must not advertise unavailable operations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## #221 — Primary UI panels retained independent window positions
|
## #221 — Primary UI panels retained independent window positions
|
||||||
|
|
||||||
**Status:** DONE — 2026-07-17
|
**Status:** DONE — 2026-07-17
|
||||||
|
|
@ -77,12 +152,6 @@ placement and restore-previous behavior.
|
||||||
Inventory, Character/Skills, and Spellbook/Components retain one position
|
Inventory, Character/Skills, and Spellbook/Components retain one position
|
||||||
across switching, dragging, closing, and reopening.
|
across switching, dragging, closing, and reopening.
|
||||||
|
|
||||||
**Acceptance:** Open Inventory, move it, then switch directly and after closing
|
|
||||||
to Character/Skills and Spellbook/Components. Every replacement must open at
|
|
||||||
the most recently moved main-panel position. Move another panel and repeat in
|
|
||||||
the opposite direction. Helpful/Harmful effects must not overwrite that
|
|
||||||
position.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## #220 — Local player glides before locomotion animation starts
|
## #220 — Local player glides before locomotion animation starts
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ accepted-divergence entries (#96, #49, #50).
|
||||||
| IA-9 | One unified camera matrix for terrain — retail's separate `LScape::update_viewpoint` landscape viewpoint does not exist | `src/AcDream.App/Rendering/TerrainModernRenderer.cs:266` | Phase W T4.2: with one matrix everywhere, viewpoint-desync bugs are unrepresentable — the unification IS the correctness argument | Anything retail derives from the landcell-relative viewpoint (float precision at extreme coords, viewpoint-keyed state) has no analogue; a future port expecting it silently reads the camera | `LScape::update_viewpoint`; `LScape::draw` 0x00506330 |
|
| IA-9 | One unified camera matrix for terrain — retail's separate `LScape::update_viewpoint` landscape viewpoint does not exist | `src/AcDream.App/Rendering/TerrainModernRenderer.cs:266` | Phase W T4.2: with one matrix everywhere, viewpoint-desync bugs are unrepresentable — the unification IS the correctness argument | Anything retail derives from the landcell-relative viewpoint (float precision at extreme coords, viewpoint-keyed state) has no analogue; a future port expecting it silently reads the camera | `LScape::update_viewpoint`; `LScape::draw` 0x00506330 |
|
||||||
| IA-10 | Transparent groups sorted back-to-front per GROUP by first-instance position (no within-group sort) vs retail per-poly BSP-order draw | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:1364` (comparer :1662) | One MDI call per pass requires group-granularity ordering; per-poly sorting is incompatible with instanced multi-draw; works when group instances are spatially coherent | Spatially spread or interleaved transparent groups composite in the wrong order — popping / wrong see-through layering as the camera moves | retail per-poly BSP-order transparent draw (D3DPolyRender / PView::DrawCells) |
|
| IA-10 | Transparent groups sorted back-to-front per GROUP by first-instance position (no within-group sort) vs retail per-poly BSP-order draw | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:1364` (comparer :1662) | One MDI call per pass requires group-granularity ordering; per-poly sorting is incompatible with instanced multi-draw; works when group instances are spatially coherent | Spatially spread or interleaved transparent groups composite in the wrong order — popping / wrong see-through layering as the camera moves | retail per-poly BSP-order transparent draw (D3DPolyRender / PView::DrawCells) |
|
||||||
| IA-11 | Tier-1 cross-frame batch-classification cache for static entities (retail re-walks part arrays every frame) | `src/AcDream.App/Rendering/Wb/EntityClassificationCache.cs:12` | Issue #53 perf tier; invariants documented (keys = EntityId + OWNING-landblock hint post-**#119** fix `2163308`; invalidation at despawn/LB-unload; mutation audit 2026-05-10) | Key collision or missed invalidation serves one entity another's batches — session-sticky wrong meshes (the #119 broken-stairs/water-barrel symptom) | retail per-frame part-array classification (no cache) |
|
| IA-11 | Tier-1 cross-frame batch-classification cache for static entities (retail re-walks part arrays every frame) | `src/AcDream.App/Rendering/Wb/EntityClassificationCache.cs:12` | Issue #53 perf tier; invariants documented (keys = EntityId + OWNING-landblock hint post-**#119** fix `2163308`; invalidation at despawn/LB-unload; mutation audit 2026-05-10) | Key collision or missed invalidation serves one entity another's batches — session-sticky wrong meshes (the #119 broken-stairs/water-barrel symptom) | retail per-frame part-array classification (no cache) |
|
||||||
| IA-12 | UI toolkit mirrors retail behavior from research docs, not a byte-port — keystone.dll is outside decomp coverage; observed constants embedded (drag 3 px, tooltip 1000 ms). `gmPanelUI` primary children are independently imported retained frames rather than one physical parent, but `RetailPanelUiController` owns their one canonical placement and exclusive child lifecycle | `src/AcDream.App/UI/README.md:3`; `src/AcDream.App/UI/Layout/RetailPanelUiController.cs` | keystone.dll has no PDB/decomp; semantics are reconstructed from the retail UI deep-dives and named `gmPanelUI` methods. Separate child wrappers preserve each LayoutDesc's content/resize policy while typed handle synchronization gives Inventory/Character/Magic the same persistent parent position | Edge-case input semantics can differ silently; if retail relies on parent-size anchoring across a panel switch, separate child extents may resize differently even though placement and visibility match | `gmPanelUI::SetupChildren` 0x004BC9E0; `RecvNotice_SetPanelVisibility` 0x004BC6F0; docs/research/retail-ui/04-input-events.md |
|
| IA-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). Synthetic wrapper borders/whole-window drag regions use the exact DAT Type-2/Type-9 control cursors. `gmPanelUI` primary children are independently imported retained frames rather than one physical parent, but `RetailPanelUiController` owns their one canonical placement and exclusive child lifecycle | `src/AcDream.App/UI/README.md:3`; `src/AcDream.App/UI/CursorFeedbackController.cs`; `src/AcDream.App/UI/Layout/RetailPanelUiController.cs` | keystone.dll has no PDB/decomp; semantics are reconstructed from the retail UI deep-dives and named client methods plus production LayoutDesc media. Separate child wrappers preserve each LayoutDesc's content/resize policy while typed handle synchronization gives Inventory/Character/Magic the same persistent parent position | Edge-case input semantics can differ silently; if retail relies on parent-size anchoring across a panel switch, separate child extents may resize differently even though placement and visibility match | `UIElementManager::CheckCursor` 0x0045ABF0; `UIElement_Resizebar::StartMouseResizing` 0x0046B7E0; `UIElement_Dragbar::StartMouseMoving` 0x0046C760; `gmPanelUI::SetupChildren` 0x004BC9E0; 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. `RetailUiRuntime` owns the production import/mount graph; `RetailWindowManager`/typed handles centralize registry, raise, focus/capture cleanup, lifecycle events, reverse-order grouped controller teardown, removable Silk input subscriptions, schema-v2 per-character/per-resolution automatic layouts, and portable named `saveui/loadui` profiles; `RetailWindowFrame` is the single production/Studio mount contract for imported-chrome and shared-wrapper windows. Production LayoutDesc imports include vitals `0x2100006C`, chat `0x21000006`, toolbar `0x21000016`, character `0x2100002E`, inventory `0x21000023` plus mounted `0x21000024/22/21`, dialog catalog `0x2100003C`, and radar `0x21000074`. The dialog context/queue/callback lifecycle is now a named-client port; only its retained rendering remains under this Keystone adaptation. | `src/AcDream.App/UI/RetailUiRuntime.cs`; `src/AcDream.App/UI/RetailWindowManager.cs`; `src/AcDream.App/UI/RetainedPanelControllerGroup.cs`; `src/AcDream.App/UI/UiHost.cs`; `src/AcDream.App/UI/RetailWindowLayoutPersistence.cs`; `src/AcDream.App/UI/Layout/RetailWindowFrame.cs`; `src/AcDream.App/UI/Layout/RetailDialogFactory.cs`; `src/AcDream.App/UI/Layout/RetailConfirmationDialogView.cs`; `src/AcDream.App/UI/Layout/LayoutImporter.cs`; binding supply in `GameWindow.cs` | Keystone has no matching PDB/decomp, so we preserve its observable ElementDesc/state/input behavior from DAT, named client call sites, and live evidence while using modern retained ownership. Real RenderSurfaces and imported element geometry remain the visual oracle. | Persistence and low-level widget rendering are behaviorally reconstructed from retail semantics rather than a Keystone byte-port; lifecycle edge cases remain constrained by conformance tests | Production LayoutDesc objects; `DialogFactory @ 0x004773C0..0x00478470`; `docs/research/2026-07-13-retail-dialog-factory-pseudocode.md`; Keystone behavior notes in `docs/research/retail-ui/` |
|
| IA-15 | D.2b gameplay UI is our own `UiHost`/`UiRoot` retained tree, not a byte-port of Keystone. `RetailUiRuntime` owns the production import/mount graph; `RetailWindowManager`/typed handles centralize registry, raise, focus/capture cleanup, lifecycle events, reverse-order grouped controller teardown, removable Silk input subscriptions, schema-v2 per-character/per-resolution automatic layouts, and portable named `saveui/loadui` profiles; `RetailWindowFrame` is the single production/Studio mount contract for imported-chrome and shared-wrapper windows. Production LayoutDesc imports include vitals `0x2100006C`, chat `0x21000006`, toolbar `0x21000016`, character `0x2100002E`, inventory `0x21000023` plus mounted `0x21000024/22/21`, dialog catalog `0x2100003C`, and radar `0x21000074`. The dialog context/queue/callback lifecycle is now a named-client port; only its retained rendering remains under this Keystone adaptation. | `src/AcDream.App/UI/RetailUiRuntime.cs`; `src/AcDream.App/UI/RetailWindowManager.cs`; `src/AcDream.App/UI/RetainedPanelControllerGroup.cs`; `src/AcDream.App/UI/UiHost.cs`; `src/AcDream.App/UI/RetailWindowLayoutPersistence.cs`; `src/AcDream.App/UI/Layout/RetailWindowFrame.cs`; `src/AcDream.App/UI/Layout/RetailDialogFactory.cs`; `src/AcDream.App/UI/Layout/RetailConfirmationDialogView.cs`; `src/AcDream.App/UI/Layout/LayoutImporter.cs`; binding supply in `GameWindow.cs` | Keystone has no matching PDB/decomp, so we preserve its observable ElementDesc/state/input behavior from DAT, named client call sites, and live evidence while using modern retained ownership. Real RenderSurfaces and imported element geometry remain the visual oracle. | Persistence and low-level widget rendering are behaviorally reconstructed from retail semantics rather than a Keystone byte-port; lifecycle edge cases remain constrained by conformance tests | Production LayoutDesc objects; `DialogFactory @ 0x004773C0..0x00478470`; `docs/research/2026-07-13-retail-dialog-factory-pseudocode.md`; Keystone behavior notes in `docs/research/retail-ui/` |
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,36 @@ Magic are children of one `gmPanelUI`. The switch changes child visibility;
|
||||||
there is no child move and no per-panel top-level position. Dragging the parent
|
there is no child move and no per-panel top-level position. Dragging the parent
|
||||||
therefore moves the one position subsequently used by every child.
|
therefore moves the one position subsequently used by every child.
|
||||||
|
|
||||||
|
## Resize follow-up
|
||||||
|
|
||||||
|
The production end-of-retail LayoutDesc supplies the missing geometry oracle:
|
||||||
|
|
||||||
|
```text
|
||||||
|
LayoutDesc 0x2100006E
|
||||||
|
gmPanelUI host 0x100005FE 310 x 372
|
||||||
|
content parent 0x10000180 300 x 362, anchored on all edges
|
||||||
|
Inventory child 0x1000018B 300 x 362, anchored on all edges
|
||||||
|
Character child 0x1000018E 300 x 362, anchored on all edges
|
||||||
|
Magic child 0x10000190 300 x 362, anchored on all edges
|
||||||
|
|
||||||
|
top-center 0x1000065C Type 2 Dragbar, cursor 0x06006119
|
||||||
|
bottom-center 0x10000660 Type 9 Resizebar, cursor 0x06005E66
|
||||||
|
```
|
||||||
|
|
||||||
|
Every primary child therefore participates in the shared host's vertical
|
||||||
|
resize; Magic is not a fixed-height exception. Named
|
||||||
|
`gmPanelUI::ResizeTo @ 0x004BC6E0` delegates directly to
|
||||||
|
`UIElement::ResizeTo @ 0x00463C30`, whose only size limits are effective DAT
|
||||||
|
properties `0x3C..0x3F`. There is no retail 760-pixel Character limit and no
|
||||||
|
maximum captured from the host's original screen position.
|
||||||
|
|
||||||
|
acdream retains independent wrappers under IA-12, so the faithful adaptation
|
||||||
|
is to give each primary wrapper the same bottom-only vertical resize policy,
|
||||||
|
leave its maximum unbounded by an authored substitute, and calculate the
|
||||||
|
available desktop extent from its current position during the resize. The
|
||||||
|
viewport remains the physical limit; an old static maximum must not become a
|
||||||
|
smaller limit after the shared panel moves.
|
||||||
|
|
||||||
## acdream port shape
|
## acdream port shape
|
||||||
|
|
||||||
acdream imports these child LayoutDesc roots independently, so each currently
|
acdream imports these child LayoutDesc roots independently, so each currently
|
||||||
|
|
@ -105,7 +135,9 @@ The controller owns this synchronization and subscribes to typed
|
||||||
`RetailWindowHandle.Moved` events. Toolbar commands, keyboard commands,
|
`RetailWindowHandle.Moved` events. Toolbar commands, keyboard commands,
|
||||||
restore-previous effects, persistence restore, and direct window dragging all
|
restore-previous effects, persistence restore, and direct window dragging all
|
||||||
therefore pass through the same owner. Panel-authored size/resize policy stays
|
therefore pass through the same owner. Panel-authored size/resize policy stays
|
||||||
with the active child; only the retail parent placement is shared.
|
with the active child, but the three primary wrappers apply the shared host's
|
||||||
|
same vertical resize contract; only placement is synchronized between the
|
||||||
|
independent frames.
|
||||||
|
|
||||||
ACE contains no client UI implementation, and the in-tree WorldBuilder
|
ACE contains no client UI implementation, and the in-tree WorldBuilder
|
||||||
reference contains no `gmPanelUI` port, so neither supplies a competing
|
reference contains no `gmPanelUI` port, so neither supplies a competing
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,137 @@
|
||||||
|
# Retail window-control cursor pseudocode
|
||||||
|
|
||||||
|
Date: 2026-07-17
|
||||||
|
|
||||||
|
## Question
|
||||||
|
|
||||||
|
Which cursors does the end-of-retail client show while hovering or operating
|
||||||
|
window dragbars and resizebars, and how are they selected?
|
||||||
|
|
||||||
|
## Named-retail mechanism
|
||||||
|
|
||||||
|
### Widget cursor ownership
|
||||||
|
|
||||||
|
```text
|
||||||
|
MediaMachine::Update_Cursor(owner, cursorMedia) // 0x00465A80
|
||||||
|
if cursorMedia.file is valid
|
||||||
|
owner.SetCursor(file, hotspotX, hotspotY)
|
||||||
|
else
|
||||||
|
owner.UnSetCursor()
|
||||||
|
|
||||||
|
UIElement::SetCursor(file, hotspotX, hotspotY) // 0x0045FF50
|
||||||
|
m_cursorDID = file
|
||||||
|
m_cursorHotX = hotspotX
|
||||||
|
m_cursorHotY = hotspotY
|
||||||
|
UIElementManager.CheckCursor()
|
||||||
|
|
||||||
|
UIElementManager::CheckCursor() // 0x0045ABF0
|
||||||
|
if mouseCapture != null and mouseCapture.HasCursor()
|
||||||
|
apply mouseCapture cursor
|
||||||
|
else if lastEntered != null and lastEntered.HasCursor()
|
||||||
|
apply lastEntered cursor
|
||||||
|
else
|
||||||
|
apply ClientUISystem default cursor
|
||||||
|
```
|
||||||
|
|
||||||
|
The important ownership rule is that the captured widget wins. A resize or
|
||||||
|
move cursor therefore remains visible after the pointer leaves the original
|
||||||
|
five-pixel control while the button is still held.
|
||||||
|
|
||||||
|
### Window controls
|
||||||
|
|
||||||
|
```text
|
||||||
|
UIElement_Resizebar::StartMouseResizing(point) // 0x0046B7E0
|
||||||
|
parent = GetParent()
|
||||||
|
edge = authored Left/Right/Top/Bottom attributes
|
||||||
|
m_mousePressed = true
|
||||||
|
parent.StartResizing(edge, point.x, point.y)
|
||||||
|
SetState(Pressed)
|
||||||
|
|
||||||
|
UIElement_Resizebar::StopMouseResizing() // 0x0046B8C0
|
||||||
|
SetState(Normal)
|
||||||
|
m_mousePressed = false
|
||||||
|
GetParent().StopResizing()
|
||||||
|
|
||||||
|
UIElement_Dragbar::StartMouseMoving(message) // 0x0046C760
|
||||||
|
if primary mouse button and parent is not already moving/resizing
|
||||||
|
m_mousePressed = true
|
||||||
|
parent.StartMovement(message.windowX, message.windowY)
|
||||||
|
SetState(Pressed)
|
||||||
|
|
||||||
|
UIElement_Dragbar::StopMouseMoving(message) // 0x0046C7C0
|
||||||
|
if pressed and primary mouse button
|
||||||
|
SetState(Normal)
|
||||||
|
m_mousePressed = false
|
||||||
|
GetParent().StopMovement()
|
||||||
|
```
|
||||||
|
|
||||||
|
Dragbar and Resizebar do not synthesize OS cursors in these methods. Their
|
||||||
|
LayoutDesc state media supplies `MD_Data_Cursor`; the generic
|
||||||
|
`MediaMachine::Update_Cursor` path above installs it on the element.
|
||||||
|
|
||||||
|
## Installed end-of-retail DAT oracle
|
||||||
|
|
||||||
|
The production LayoutDesc records and portal DAT resolve these direct cursor
|
||||||
|
surfaces, all with hotspot `(16, 16)` pixels:
|
||||||
|
|
||||||
|
| Intent | RenderSurface DID | Observed authored use |
|
||||||
|
|---|---:|---|
|
||||||
|
| Move window | `0x06006119` | Type-2 Dragbar controls |
|
||||||
|
| Resize vertically | `0x06005E66` | horizontal Type-9 Resizebar (main chat top edge and combat bottom edge) |
|
||||||
|
| Resize horizontally | `0x06006128` | vertical Type-9 Resizebar |
|
||||||
|
| Resize NW-SE | `0x06006126` | top-left and bottom-right Type-9 corners |
|
||||||
|
| Resize NE-SW | `0x06006127` | bottom-left / opposite diagonal Type-9 corner |
|
||||||
|
|
||||||
|
All five surfaces are 32×32 `PFID_A8R8G8B8` cursor images. Representative
|
||||||
|
LayoutDesc fixtures independently preserve the same media:
|
||||||
|
|
||||||
|
- main chat `0x21000006`, element `0x1000000F`: vertical cursor
|
||||||
|
`0x06005E66` at `(16,16)`;
|
||||||
|
- combat `0x21000073`, Dragbar `0x100006AE`: move cursor
|
||||||
|
`0x06006119` at `(16,16)`;
|
||||||
|
- combat corner/edge Resizebars: `0x06006126..0x06006128`.
|
||||||
|
|
||||||
|
`DatReaderWriter` supplies these cursor records through the existing
|
||||||
|
LayoutDesc media decode. WorldBuilder's backend exposes the same architectural
|
||||||
|
boundary (`SetCursor(cursorDataId, hotspot)`) but leaves it unimplemented; it
|
||||||
|
does not provide a competing behavior interpretation.
|
||||||
|
|
||||||
|
## acdream root cause and port
|
||||||
|
|
||||||
|
`UiRoot` already computes the active or hovered resize edges and window-move
|
||||||
|
ownership. `CursorFeedbackController` already translates those states into
|
||||||
|
`WindowMove`, `ResizeHorizontal`, `ResizeVertical`, and the two diagonal
|
||||||
|
semantic kinds. However, `RetailCursorManager` renders only a valid
|
||||||
|
`UiCursorMedia` override or the `ClientUISystem` global cursor. The semantic
|
||||||
|
window-control kind was never converted to media, so synthetic wrapper borders
|
||||||
|
and whole-window drag regions retained the normal pointer.
|
||||||
|
|
||||||
|
Port shape:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ResolveWindowControlCursor(kind):
|
||||||
|
WindowMove -> 0x06006119 @ (16,16)
|
||||||
|
ResizeHorizontal -> 0x06006128 @ (16,16)
|
||||||
|
ResizeVertical -> 0x06005E66 @ (16,16)
|
||||||
|
ResizeDiagonalNWSE -> 0x06006126 @ (16,16)
|
||||||
|
ResizeDiagonalNESW -> 0x06006127 @ (16,16)
|
||||||
|
|
||||||
|
UpdateCursor(root):
|
||||||
|
kind = resolve semantic interaction state
|
||||||
|
authored = captured-or-hovered element cursor, using retail precedence
|
||||||
|
|
||||||
|
if a synthetic resize edge owns the pointer or a resize/move operation owns capture
|
||||||
|
cursor = ResolveWindowControlCursor(kind)
|
||||||
|
else if authored is valid
|
||||||
|
cursor = authored
|
||||||
|
else
|
||||||
|
cursor = ResolveWindowControlCursor(kind)
|
||||||
|
|
||||||
|
render cursor; otherwise retain the ClientUISystem global cursor
|
||||||
|
```
|
||||||
|
|
||||||
|
Authored widget media therefore remains the first hover-time oracle. The
|
||||||
|
shared fallback exists for retained wrapper borders and the accepted IA-12
|
||||||
|
whole-window drag adaptation, which have no authored Dragbar/Resizebar child.
|
||||||
|
Once a synthetic operation starts, its control cursor wins just as retail's
|
||||||
|
captured Dragbar or Resizebar wins.
|
||||||
|
|
@ -140,7 +140,9 @@ public sealed class CursorFeedbackController
|
||||||
CombatMode: _combatModeProvider());
|
CombatMode: _combatModeProvider());
|
||||||
|
|
||||||
var kind = ResolveKind(snapshot);
|
var kind = ResolveKind(snapshot);
|
||||||
Current = new CursorFeedback(kind, ResolveCursor(root.Captured, hover), ResolveGlobalKind(snapshot));
|
UiCursorMedia authoredCursor = ResolveCursor(root.Captured, hover);
|
||||||
|
UiCursorMedia cursor = ResolveEffectiveCursor(kind, snapshot, authoredCursor);
|
||||||
|
Current = new CursorFeedback(kind, cursor, ResolveGlobalKind(snapshot));
|
||||||
return Current;
|
return Current;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -151,7 +153,13 @@ public sealed class CursorFeedbackController
|
||||||
}
|
}
|
||||||
|
|
||||||
public CursorFeedback Resolve(CursorFeedbackSnapshot snapshot)
|
public CursorFeedback Resolve(CursorFeedbackSnapshot snapshot)
|
||||||
=> new(ResolveKind(snapshot), GlobalKind: ResolveGlobalKind(snapshot));
|
{
|
||||||
|
CursorFeedbackKind kind = ResolveKind(snapshot);
|
||||||
|
return new(
|
||||||
|
kind,
|
||||||
|
ResolveEffectiveCursor(kind, snapshot, authoredCursor: default),
|
||||||
|
ResolveGlobalKind(snapshot));
|
||||||
|
}
|
||||||
|
|
||||||
private CursorFeedbackKind ResolveKind(CursorFeedbackSnapshot snapshot)
|
private CursorFeedbackKind ResolveKind(CursorFeedbackSnapshot snapshot)
|
||||||
{
|
{
|
||||||
|
|
@ -310,6 +318,34 @@ public sealed class CursorFeedbackController
|
||||||
return default;
|
return default;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static UiCursorMedia ResolveEffectiveCursor(
|
||||||
|
CursorFeedbackKind kind,
|
||||||
|
CursorFeedbackSnapshot snapshot,
|
||||||
|
UiCursorMedia authoredCursor)
|
||||||
|
{
|
||||||
|
bool syntheticControlOwnsPointer = snapshot.ActiveResizeEdges != ResizeEdges.None
|
||||||
|
|| snapshot.HoverResizeEdges != ResizeEdges.None
|
||||||
|
|| snapshot.WindowMoveActive;
|
||||||
|
|
||||||
|
// Retail CheckCursor gives the hovered/captured Resizebar or captured
|
||||||
|
// Dragbar first refusal. Our wrapper borders are geometry, not UIElements,
|
||||||
|
// so a hovered synthetic resize edge or active synthetic control must take
|
||||||
|
// that same precedence over the content under its five-pixel hit region.
|
||||||
|
if (syntheticControlOwnsPointer
|
||||||
|
&& RetailCursorCatalog.TryGetWindowControlCursor(kind, out UiCursorMedia capturedControl))
|
||||||
|
return capturedControl;
|
||||||
|
|
||||||
|
// Ordinary imported elements retain their authored MD_Data_Cursor exactly.
|
||||||
|
if (authoredCursor.IsValid)
|
||||||
|
return authoredCursor;
|
||||||
|
|
||||||
|
// Synthetic wrapper edges and the IA-12 whole-window drag region have no
|
||||||
|
// authored child, but use retail's exact control media and hotspot.
|
||||||
|
return RetailCursorCatalog.TryGetWindowControlCursor(kind, out UiCursorMedia fallback)
|
||||||
|
? fallback
|
||||||
|
: default;
|
||||||
|
}
|
||||||
|
|
||||||
private static UiItemSlot? FindHoveredItemSlot(UiElement? element)
|
private static UiItemSlot? FindHoveredItemSlot(UiElement? element)
|
||||||
{
|
{
|
||||||
while (element is not null)
|
while (element is not null)
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ public static class RetailWindowFrame
|
||||||
public ResizeEdges ResizableEdges { get; init; } =
|
public ResizeEdges ResizableEdges { get; init; } =
|
||||||
ResizeEdges.Left | ResizeEdges.Right | ResizeEdges.Top | ResizeEdges.Bottom;
|
ResizeEdges.Left | ResizeEdges.Right | ResizeEdges.Top | ResizeEdges.Bottom;
|
||||||
public bool ConstrainDragToParent { get; init; }
|
public bool ConstrainDragToParent { get; init; }
|
||||||
|
public bool ConstrainResizeToParent { get; init; }
|
||||||
|
|
||||||
public float Opacity { get; init; } = 1f;
|
public float Opacity { get; init; } = 1f;
|
||||||
public bool Visible { get; init; } = true;
|
public bool Visible { get; init; } = true;
|
||||||
|
|
@ -135,6 +136,7 @@ public static class RetailWindowFrame
|
||||||
outerFrame.ResizeY = options.ResizeY;
|
outerFrame.ResizeY = options.ResizeY;
|
||||||
outerFrame.ResizableEdges = options.ResizableEdges;
|
outerFrame.ResizableEdges = options.ResizableEdges;
|
||||||
outerFrame.ConstrainDragToParent = options.ConstrainDragToParent;
|
outerFrame.ConstrainDragToParent = options.ConstrainDragToParent;
|
||||||
|
outerFrame.ConstrainResizeToParent = options.ConstrainResizeToParent;
|
||||||
outerFrame.Opacity = Math.Clamp(options.Opacity, 0f, 1f);
|
outerFrame.Opacity = Math.Clamp(options.Opacity, 0f, 1f);
|
||||||
outerFrame.Visible = options.Visible;
|
outerFrame.Visible = options.Visible;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
namespace AcDream.App.UI;
|
namespace AcDream.App.UI;
|
||||||
|
|
||||||
/// <summary>Retail global cursor enum metadata used outside LayoutDesc widget states.</summary>
|
/// <summary>
|
||||||
|
/// Retail cursor metadata used outside ordinary LayoutDesc widget-state lookup.
|
||||||
|
/// Global cursors resolve through enum table 6; synthetic retained-window control
|
||||||
|
/// regions use the exact direct cursor media authored on retail Dragbar/Resizebar
|
||||||
|
/// elements.
|
||||||
|
/// </summary>
|
||||||
internal static class RetailCursorCatalog
|
internal static class RetailCursorCatalog
|
||||||
{
|
{
|
||||||
public const uint CursorEnumTable = 6;
|
public const uint CursorEnumTable = 6;
|
||||||
|
|
@ -32,6 +37,31 @@ internal static class RetailCursorCatalog
|
||||||
|
|
||||||
return spec.IsValid;
|
return spec.IsValid;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Direct <c>MD_Data_Cursor</c> surfaces authored on retail Type-2 Dragbar and
|
||||||
|
/// Type-9 Resizebar controls. The retained wrapper border has no LayoutDesc
|
||||||
|
/// element of its own, so it uses the same media through this shared seam.
|
||||||
|
/// </summary>
|
||||||
|
public static bool TryGetWindowControlCursor(
|
||||||
|
CursorFeedbackKind kind,
|
||||||
|
out UiCursorMedia cursor)
|
||||||
|
{
|
||||||
|
// MediaMachine::Update_Cursor @ 0x00465A80 installs these element-local
|
||||||
|
// cursor DIDs; UIElementManager::CheckCursor @ 0x0045ABF0 preserves the
|
||||||
|
// captured Dragbar/Resizebar cursor for the complete operation.
|
||||||
|
cursor = kind switch
|
||||||
|
{
|
||||||
|
CursorFeedbackKind.WindowMove => new UiCursorMedia(0x06006119u, 16, 16),
|
||||||
|
CursorFeedbackKind.ResizeHorizontal => new UiCursorMedia(0x06006128u, 16, 16),
|
||||||
|
CursorFeedbackKind.ResizeVertical => new UiCursorMedia(0x06005E66u, 16, 16),
|
||||||
|
CursorFeedbackKind.ResizeDiagonalNwse => new UiCursorMedia(0x06006126u, 16, 16),
|
||||||
|
CursorFeedbackKind.ResizeDiagonalNesw => new UiCursorMedia(0x06006127u, 16, 16),
|
||||||
|
_ => default,
|
||||||
|
};
|
||||||
|
|
||||||
|
return cursor.IsValid;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal readonly record struct RetailCursorSpec(uint EnumId, int HotspotX, int HotspotY)
|
internal readonly record struct RetailCursorSpec(uint EnumId, int HotspotX, int HotspotY)
|
||||||
|
|
|
||||||
|
|
@ -546,6 +546,10 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
MinWidth = 200f,
|
MinWidth = 200f,
|
||||||
ResizeX = true,
|
ResizeX = true,
|
||||||
ResizeY = true,
|
ResizeY = true,
|
||||||
|
// The imported 9px top Resizebar is deliberately removed by
|
||||||
|
// ChatWindowController. Its replacement top chrome is the move
|
||||||
|
// affordance; only the other three wrapper edges resize.
|
||||||
|
ResizableEdges = ResizeEdges.Left | ResizeEdges.Right | ResizeEdges.Bottom,
|
||||||
Opacity = 0.75f,
|
Opacity = 0.75f,
|
||||||
Controller = controller,
|
Controller = controller,
|
||||||
StateController = controller,
|
StateController = controller,
|
||||||
|
|
@ -798,10 +802,12 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
Left = 18f,
|
Left = 18f,
|
||||||
Top = 18f,
|
Top = 18f,
|
||||||
Visible = false,
|
Visible = false,
|
||||||
Resizable = false,
|
|
||||||
ResizeX = false,
|
ResizeX = false,
|
||||||
ResizeY = false,
|
ResizeY = true,
|
||||||
|
ResizableEdges = ResizeEdges.Bottom,
|
||||||
ConstrainDragToParent = true,
|
ConstrainDragToParent = true,
|
||||||
|
ConstrainResizeToParent = true,
|
||||||
|
ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom,
|
||||||
ContentClickThrough = false,
|
ContentClickThrough = false,
|
||||||
Controller = controller,
|
Controller = controller,
|
||||||
});
|
});
|
||||||
|
|
@ -1147,10 +1153,10 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
Chrome = RetailWindowChrome.NineSlice,
|
Chrome = RetailWindowChrome.NineSlice,
|
||||||
Left = 540f,
|
Left = 540f,
|
||||||
Top = 18f,
|
Top = 18f,
|
||||||
MaxHeight = 760f,
|
|
||||||
ResizeX = false,
|
ResizeX = false,
|
||||||
ResizeY = true,
|
ResizeY = true,
|
||||||
ResizableEdges = ResizeEdges.Bottom,
|
ResizableEdges = ResizeEdges.Bottom,
|
||||||
|
ConstrainResizeToParent = true,
|
||||||
Visible = false,
|
Visible = false,
|
||||||
ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom,
|
ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom,
|
||||||
ContentClickThrough = false,
|
ContentClickThrough = false,
|
||||||
|
|
@ -1233,11 +1239,7 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
ResizeX = false,
|
ResizeX = false,
|
||||||
ResizeY = true,
|
ResizeY = true,
|
||||||
ResizableEdges = ResizeEdges.Bottom,
|
ResizableEdges = ResizeEdges.Bottom,
|
||||||
// The old 560px cap left a previously expanded window permanently
|
ConstrainResizeToParent = true,
|
||||||
// parked at its maximum. Let it grow to the available screen height.
|
|
||||||
MaxHeight = Math.Max(
|
|
||||||
root.Height + 2f * RetailChromeSprites.Border,
|
|
||||||
Host.Root.Height - root.Top),
|
|
||||||
ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom,
|
ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom,
|
||||||
});
|
});
|
||||||
_panelUi.RegisterMainPanel(
|
_panelUi.RegisterMainPanel(
|
||||||
|
|
|
||||||
|
|
@ -155,8 +155,15 @@ 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, frame.MaxWidth);
|
float maxWidth = frame.MaxWidth;
|
||||||
height = Math.Clamp(height, frame.MinHeight, frame.MaxHeight);
|
float maxHeight = frame.MaxHeight;
|
||||||
|
if (frame.ConstrainResizeToParent && frame.Parent is { } parent)
|
||||||
|
{
|
||||||
|
maxWidth = MathF.Min(maxWidth, parent.Width - frame.Left);
|
||||||
|
maxHeight = MathF.Min(maxHeight, parent.Height - frame.Top);
|
||||||
|
}
|
||||||
|
width = Math.Clamp(width, frame.MinWidth, MathF.Max(frame.MinWidth, maxWidth));
|
||||||
|
height = Math.Clamp(height, frame.MinHeight, MathF.Max(frame.MinHeight, 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;
|
||||||
frame.Height = height;
|
frame.Height = height;
|
||||||
|
|
|
||||||
|
|
@ -188,6 +188,13 @@ public abstract class UiElement
|
||||||
/// <c>gmRadarUI::MoveTo</c> enables this; most windows retain the toolkit default.</summary>
|
/// <c>gmRadarUI::MoveTo</c> enables this; most windows retain the toolkit default.</summary>
|
||||||
public bool ConstrainDragToParent { get; set; }
|
public bool ConstrainDragToParent { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Clamp resize growth to the current parent extent. This is distinct from
|
||||||
|
/// <see cref="ConstrainDragToParent"/>: retail's shared main-panel host may be
|
||||||
|
/// moved freely, but its bottom resizebar grows only to the desktop edge.
|
||||||
|
/// </summary>
|
||||||
|
public bool ConstrainResizeToParent { get; set; }
|
||||||
|
|
||||||
/// <summary>If true, a left-drag starting near this element's edge/corner
|
/// <summary>If true, a left-drag starting near this element's edge/corner
|
||||||
/// resizes it (window resize). Intended for top-level panels.</summary>
|
/// resizes it (window resize). Intended for top-level panels.</summary>
|
||||||
public bool Resizable { get; set; }
|
public bool Resizable { get; set; }
|
||||||
|
|
|
||||||
|
|
@ -341,11 +341,31 @@ public sealed class UiRoot : UiElement
|
||||||
// Window resize takes precedence over move / drag-drop / hover.
|
// Window resize takes precedence over move / drag-drop / hover.
|
||||||
if (_resizeTarget is not null)
|
if (_resizeTarget is not null)
|
||||||
{
|
{
|
||||||
|
float maxWidth = _resizeTarget.MaxWidth;
|
||||||
|
float maxHeight = _resizeTarget.MaxHeight;
|
||||||
|
if (_resizeTarget.ConstrainResizeToParent
|
||||||
|
&& _resizeTarget.Parent is { } resizeParent)
|
||||||
|
{
|
||||||
|
// The opposite edge remains fixed during a resize. Limit the
|
||||||
|
// dragged edge to its current parent, using the interaction's
|
||||||
|
// start rect so the clamp remains stable throughout the drag.
|
||||||
|
maxWidth = MathF.Min(
|
||||||
|
maxWidth,
|
||||||
|
(_resizeEdges & ResizeEdges.Left) != 0
|
||||||
|
? _resizeStartX + _resizeStartW
|
||||||
|
: resizeParent.Width - _resizeStartX);
|
||||||
|
maxHeight = MathF.Min(
|
||||||
|
maxHeight,
|
||||||
|
(_resizeEdges & ResizeEdges.Top) != 0
|
||||||
|
? _resizeStartY + _resizeStartH
|
||||||
|
: resizeParent.Height - _resizeStartY);
|
||||||
|
}
|
||||||
var (nx, ny, nw, nh) = ResizeRect(
|
var (nx, ny, nw, nh) = ResizeRect(
|
||||||
_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,
|
||||||
_resizeTarget.MaxWidth, _resizeTarget.MaxHeight);
|
MathF.Max(_resizeTarget.MinWidth, maxWidth),
|
||||||
|
MathF.Max(_resizeTarget.MinHeight, 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;
|
||||||
|
|
|
||||||
|
|
@ -32,17 +32,25 @@ public sealed class CursorFeedbackControllerTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData(ResizeEdges.Right, CursorFeedbackKind.ResizeHorizontal)]
|
[InlineData(ResizeEdges.Left, CursorFeedbackKind.ResizeHorizontal, 0x06006128u)]
|
||||||
[InlineData(ResizeEdges.Bottom, CursorFeedbackKind.ResizeVertical)]
|
[InlineData(ResizeEdges.Right, CursorFeedbackKind.ResizeHorizontal, 0x06006128u)]
|
||||||
[InlineData(ResizeEdges.Right | ResizeEdges.Bottom, CursorFeedbackKind.ResizeDiagonalNwse)]
|
[InlineData(ResizeEdges.Top, CursorFeedbackKind.ResizeVertical, 0x06005E66u)]
|
||||||
[InlineData(ResizeEdges.Left | ResizeEdges.Bottom, CursorFeedbackKind.ResizeDiagonalNesw)]
|
[InlineData(ResizeEdges.Bottom, CursorFeedbackKind.ResizeVertical, 0x06005E66u)]
|
||||||
public void ResizeEdges_chooseExpectedCursor(ResizeEdges edges, CursorFeedbackKind expected)
|
[InlineData(ResizeEdges.Left | ResizeEdges.Top, CursorFeedbackKind.ResizeDiagonalNwse, 0x06006126u)]
|
||||||
|
[InlineData(ResizeEdges.Right | ResizeEdges.Bottom, CursorFeedbackKind.ResizeDiagonalNwse, 0x06006126u)]
|
||||||
|
[InlineData(ResizeEdges.Left | ResizeEdges.Bottom, CursorFeedbackKind.ResizeDiagonalNesw, 0x06006127u)]
|
||||||
|
[InlineData(ResizeEdges.Right | ResizeEdges.Top, CursorFeedbackKind.ResizeDiagonalNesw, 0x06006127u)]
|
||||||
|
public void ResizeEdges_chooseExpectedRetailCursor(
|
||||||
|
ResizeEdges edges,
|
||||||
|
CursorFeedbackKind expected,
|
||||||
|
uint expectedDid)
|
||||||
{
|
{
|
||||||
var c = new CursorFeedbackController();
|
var c = new CursorFeedbackController();
|
||||||
|
|
||||||
var feedback = c.Resolve(new CursorFeedbackSnapshot(HoverResizeEdges: edges));
|
var feedback = c.Resolve(new CursorFeedbackSnapshot(HoverResizeEdges: edges));
|
||||||
|
|
||||||
Assert.Equal(expected, feedback.Kind);
|
Assert.Equal(expected, feedback.Kind);
|
||||||
|
Assert.Equal(new UiCursorMedia(expectedDid, 16, 16), feedback.Cursor);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -52,10 +60,93 @@ public sealed class CursorFeedbackControllerTests
|
||||||
|
|
||||||
Assert.Equal(CursorFeedbackKind.WindowMove,
|
Assert.Equal(CursorFeedbackKind.WindowMove,
|
||||||
c.Resolve(new CursorFeedbackSnapshot(HoverWindowMove: true)).Kind);
|
c.Resolve(new CursorFeedbackSnapshot(HoverWindowMove: true)).Kind);
|
||||||
|
Assert.Equal(new UiCursorMedia(0x06006119u, 16, 16),
|
||||||
|
c.Resolve(new CursorFeedbackSnapshot(HoverWindowMove: true)).Cursor);
|
||||||
Assert.Equal(CursorFeedbackKind.Text,
|
Assert.Equal(CursorFeedbackKind.Text,
|
||||||
c.Resolve(new CursorFeedbackSnapshot(HoverTextEdit: true)).Kind);
|
c.Resolve(new CursorFeedbackSnapshot(HoverTextEdit: true)).Kind);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ActiveSyntheticResize_keepsControlCursorOverAuthoredCapturedElement()
|
||||||
|
{
|
||||||
|
var root = new UiRoot { Width = 400, Height = 300 };
|
||||||
|
var window = new UiPanel
|
||||||
|
{
|
||||||
|
Left = 50,
|
||||||
|
Top = 40,
|
||||||
|
Width = 150,
|
||||||
|
Height = 100,
|
||||||
|
Draggable = true,
|
||||||
|
Resizable = true,
|
||||||
|
};
|
||||||
|
var content = new UiPanel { Width = 150, Height = 100 };
|
||||||
|
var authored = new UiCursorMedia(0x06009999u, 3, 4);
|
||||||
|
content.SetStateCursors(new Dictionary<string, UiCursorMedia> { [""] = authored });
|
||||||
|
window.AddChild(content);
|
||||||
|
root.AddChild(window);
|
||||||
|
var controller = new CursorFeedbackController();
|
||||||
|
|
||||||
|
root.OnMouseMove(199, 90); // inside the five-pixel right-edge resizebar region
|
||||||
|
Assert.Equal(new UiCursorMedia(0x06006128u, 16, 16), controller.Update(root).Cursor);
|
||||||
|
|
||||||
|
root.OnMouseDown(UiMouseButton.Left, 199, 90);
|
||||||
|
root.OnMouseMove(100, 90); // leave the edge while resize capture remains active
|
||||||
|
|
||||||
|
CursorFeedback active = controller.Update(root);
|
||||||
|
Assert.Equal(CursorFeedbackKind.ResizeHorizontal, active.Kind);
|
||||||
|
Assert.Equal(new UiCursorMedia(0x06006128u, 16, 16), active.Cursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AuthoredWidgetCursor_winsUntilSyntheticWindowMoveStarts()
|
||||||
|
{
|
||||||
|
var root = new UiRoot { Width = 400, Height = 300 };
|
||||||
|
var window = new UiPanel
|
||||||
|
{
|
||||||
|
Left = 50,
|
||||||
|
Top = 40,
|
||||||
|
Width = 150,
|
||||||
|
Height = 100,
|
||||||
|
Draggable = true,
|
||||||
|
};
|
||||||
|
var dragRegion = new UiPanel { Width = 150, Height = 100 };
|
||||||
|
var authored = new UiCursorMedia(0x06008888u, 7, 8);
|
||||||
|
dragRegion.SetStateCursors(new Dictionary<string, UiCursorMedia> { [""] = authored });
|
||||||
|
window.AddChild(dragRegion);
|
||||||
|
root.AddChild(window);
|
||||||
|
var controller = new CursorFeedbackController();
|
||||||
|
|
||||||
|
root.OnMouseMove(100, 90);
|
||||||
|
Assert.Equal(authored, controller.Update(root).Cursor);
|
||||||
|
|
||||||
|
root.OnMouseDown(UiMouseButton.Left, 100, 90);
|
||||||
|
Assert.Equal(new UiCursorMedia(0x06006119u, 16, 16), controller.Update(root).Cursor);
|
||||||
|
|
||||||
|
root.OnMouseMove(300, 200);
|
||||||
|
Assert.Equal(new UiCursorMedia(0x06006119u, 16, 16), controller.Update(root).Cursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UiLock_suppressesSyntheticWindowControlCursors()
|
||||||
|
{
|
||||||
|
var root = new UiRoot { Width = 400, Height = 300, UiLocked = true };
|
||||||
|
root.AddChild(new UiPanel
|
||||||
|
{
|
||||||
|
Left = 50,
|
||||||
|
Top = 40,
|
||||||
|
Width = 150,
|
||||||
|
Height = 100,
|
||||||
|
Draggable = true,
|
||||||
|
Resizable = true,
|
||||||
|
});
|
||||||
|
root.OnMouseMove(199, 90);
|
||||||
|
|
||||||
|
CursorFeedback feedback = new CursorFeedbackController().Update(root);
|
||||||
|
|
||||||
|
Assert.Equal(CursorFeedbackKind.Default, feedback.Kind);
|
||||||
|
Assert.False(feedback.Cursor.IsValid);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void TargetMode_reportsValidInvalidAndPendingTargets()
|
public void TargetMode_reportsValidInvalidAndPendingTargets()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,7 @@ public sealed class RetailWindowFrameTests
|
||||||
ResizeX = false,
|
ResizeX = false,
|
||||||
ResizeY = true,
|
ResizeY = true,
|
||||||
ResizableEdges = ResizeEdges.Bottom,
|
ResizableEdges = ResizeEdges.Bottom,
|
||||||
|
ConstrainResizeToParent = true,
|
||||||
ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right,
|
ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right,
|
||||||
ContentClickThrough = true,
|
ContentClickThrough = true,
|
||||||
});
|
});
|
||||||
|
|
@ -101,6 +102,7 @@ public sealed class RetailWindowFrameTests
|
||||||
Assert.False(frame.ResizeX);
|
Assert.False(frame.ResizeX);
|
||||||
Assert.True(frame.ResizeY);
|
Assert.True(frame.ResizeY);
|
||||||
Assert.Equal(ResizeEdges.Bottom, frame.ResizableEdges);
|
Assert.Equal(ResizeEdges.Bottom, frame.ResizableEdges);
|
||||||
|
Assert.True(frame.ConstrainResizeToParent);
|
||||||
Assert.True(content.ClickThrough);
|
Assert.True(content.ClickThrough);
|
||||||
Assert.False(content.Draggable);
|
Assert.False(content.Draggable);
|
||||||
Assert.False(content.Resizable);
|
Assert.False(content.Resizable);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
using AcDream.App.Rendering;
|
using AcDream.App.Rendering;
|
||||||
using AcDream.App.UI;
|
using AcDream.App.UI;
|
||||||
using DatReaderWriter;
|
using DatReaderWriter;
|
||||||
|
using DatReaderWriter.DBObjs;
|
||||||
using DatReaderWriter.Options;
|
using DatReaderWriter.Options;
|
||||||
using SysEnv = System.Environment;
|
using SysEnv = System.Environment;
|
||||||
|
|
||||||
|
|
@ -8,6 +9,47 @@ namespace AcDream.App.Tests.UI;
|
||||||
|
|
||||||
public sealed class RetailCursorCatalogTests
|
public sealed class RetailCursorCatalogTests
|
||||||
{
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData(CursorFeedbackKind.WindowMove, 0x06006119u)]
|
||||||
|
[InlineData(CursorFeedbackKind.ResizeHorizontal, 0x06006128u)]
|
||||||
|
[InlineData(CursorFeedbackKind.ResizeVertical, 0x06005E66u)]
|
||||||
|
[InlineData(CursorFeedbackKind.ResizeDiagonalNwse, 0x06006126u)]
|
||||||
|
[InlineData(CursorFeedbackKind.ResizeDiagonalNesw, 0x06006127u)]
|
||||||
|
public void WindowControlCursorSpecs_matchRetailLayoutDescMedia(
|
||||||
|
CursorFeedbackKind kind,
|
||||||
|
uint expectedDid)
|
||||||
|
{
|
||||||
|
Assert.True(RetailCursorCatalog.TryGetWindowControlCursor(kind, out var cursor));
|
||||||
|
Assert.Equal(new UiCursorMedia(expectedDid, 16, 16), cursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WindowControlCursorSpecs_resolveGoldenDatSurfaces()
|
||||||
|
{
|
||||||
|
string? datDir = ResolveDatDir();
|
||||||
|
if (datDir is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||||
|
CursorFeedbackKind[] kinds =
|
||||||
|
[
|
||||||
|
CursorFeedbackKind.WindowMove,
|
||||||
|
CursorFeedbackKind.ResizeHorizontal,
|
||||||
|
CursorFeedbackKind.ResizeVertical,
|
||||||
|
CursorFeedbackKind.ResizeDiagonalNwse,
|
||||||
|
CursorFeedbackKind.ResizeDiagonalNesw,
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach (CursorFeedbackKind kind in kinds)
|
||||||
|
{
|
||||||
|
Assert.True(RetailCursorCatalog.TryGetWindowControlCursor(kind, out var cursor));
|
||||||
|
Assert.True(dats.Portal.TryGet<RenderSurface>(cursor.File, out var surface), kind.ToString());
|
||||||
|
Assert.NotNull(surface);
|
||||||
|
Assert.Equal(32, surface.Width);
|
||||||
|
Assert.Equal(32, surface.Height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData(RetailGlobalCursorKind.Default, 0x01u, 0, 0)]
|
[InlineData(RetailGlobalCursorKind.Default, 0x01u, 0, 0)]
|
||||||
[InlineData(RetailGlobalCursorKind.DefaultFound, 0x02u, 0, 0)]
|
[InlineData(RetailGlobalCursorKind.DefaultFound, 0x02u, 0, 0)]
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,30 @@ public sealed class RetailWindowManagerTests
|
||||||
Assert.Equal(1, resized);
|
Assert.Equal(1, resized);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ProgrammaticResize_ConstrainedToParent_UsesCurrentWindowPosition()
|
||||||
|
{
|
||||||
|
var root = new UiRoot { Width = 800, Height = 600 };
|
||||||
|
var frame = new UiPanel
|
||||||
|
{
|
||||||
|
Left = 40,
|
||||||
|
Top = 125,
|
||||||
|
Width = 300,
|
||||||
|
Height = 200,
|
||||||
|
ResizeX = false,
|
||||||
|
ResizeY = true,
|
||||||
|
ConstrainResizeToParent = true,
|
||||||
|
MinHeight = 100,
|
||||||
|
};
|
||||||
|
root.AddChild(frame);
|
||||||
|
RetailWindowHandle handle = root.RegisterWindow("main-panel", frame);
|
||||||
|
|
||||||
|
Assert.True(handle.ResizeTo(frame.Width, 2_000f));
|
||||||
|
|
||||||
|
Assert.Equal(475f, frame.Height);
|
||||||
|
Assert.Equal(root.Height, frame.Top + frame.Height);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void FocusAndCaptureTransitions_AreScopedToOwningHandle()
|
public void FocusAndCaptureTransitions_AreScopedToOwningHandle()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -349,6 +349,56 @@ public class UiRootInputTests
|
||||||
root.OnMouseUp(UiMouseButton.Left, 338, 150);
|
root.OnMouseUp(UiMouseButton.Left, 338, 150);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BottomResize_ConstrainedToParent_ReachesCurrentScreenEdge()
|
||||||
|
{
|
||||||
|
var root = new UiRoot { Width = 800, Height = 600 };
|
||||||
|
var window = new UiPanel
|
||||||
|
{
|
||||||
|
Left = 100,
|
||||||
|
Top = 80,
|
||||||
|
Width = 300,
|
||||||
|
Height = 200,
|
||||||
|
Draggable = true,
|
||||||
|
Resizable = true,
|
||||||
|
ResizeX = false,
|
||||||
|
ResizeY = true,
|
||||||
|
ResizableEdges = ResizeEdges.Bottom,
|
||||||
|
ConstrainResizeToParent = true,
|
||||||
|
MinHeight = 100,
|
||||||
|
};
|
||||||
|
root.AddChild(window);
|
||||||
|
|
||||||
|
root.OnMouseDown(UiMouseButton.Left, 250, 279);
|
||||||
|
root.OnMouseMove(250, 2_000);
|
||||||
|
|
||||||
|
Assert.Equal(520f, window.Height);
|
||||||
|
Assert.Equal(root.Height, window.Top + window.Height);
|
||||||
|
root.OnMouseUp(UiMouseButton.Left, 250, 2_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TopWithoutTopResizeEdge_IsWindowMoveAffordance()
|
||||||
|
{
|
||||||
|
var root = new UiRoot { Width = 800, Height = 600 };
|
||||||
|
var window = new UiPanel
|
||||||
|
{
|
||||||
|
Left = 100,
|
||||||
|
Top = 80,
|
||||||
|
Width = 300,
|
||||||
|
Height = 200,
|
||||||
|
Draggable = true,
|
||||||
|
Resizable = true,
|
||||||
|
ResizableEdges = ResizeEdges.Left | ResizeEdges.Right | ResizeEdges.Bottom,
|
||||||
|
};
|
||||||
|
root.AddChild(window);
|
||||||
|
|
||||||
|
root.OnMouseMove(250, 80);
|
||||||
|
|
||||||
|
Assert.Equal(ResizeEdges.None, root.HoverResizeEdges);
|
||||||
|
Assert.True(root.HoverWindowMove);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ResizeRect_RightBottom_GrowsSizeOnly()
|
public void ResizeRect_RightBottom_GrowsSizeOnly()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue