From 06016014bc673db09ba7b0da270005a7dad2198b Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 17 Jul 2026 09:40:08 +0200 Subject: [PATCH] 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. --- docs/ISSUES.md | 81 ++++++++++- .../retail-divergence-register.md | 2 +- ...-17-retail-shared-main-panel-pseudocode.md | 34 ++++- ...retail-window-control-cursor-pseudocode.md | 137 ++++++++++++++++++ .../UI/CursorFeedbackController.cs | 40 ++++- .../UI/Layout/RetailWindowFrame.cs | 2 + src/AcDream.App/UI/RetailCursorCatalog.cs | 32 +++- src/AcDream.App/UI/RetailUiRuntime.cs | 18 ++- src/AcDream.App/UI/RetailWindowManager.cs | 11 +- src/AcDream.App/UI/UiElement.cs | 7 + src/AcDream.App/UI/UiRoot.cs | 22 ++- .../UI/CursorFeedbackControllerTests.cs | 101 ++++++++++++- .../UI/Layout/RetailWindowFrameTests.cs | 2 + .../UI/RetailCursorCatalogTests.cs | 42 ++++++ .../UI/RetailWindowManagerTests.cs | 24 +++ .../AcDream.App.Tests/UI/UiRootInputTests.cs | 50 +++++++ 16 files changed, 578 insertions(+), 27 deletions(-) create mode 100644 docs/research/2026-07-17-retail-window-control-cursor-pseudocode.md diff --git a/docs/ISSUES.md b/docs/ISSUES.md index e9170c75..f7f4e60d 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -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 **Status:** DONE — 2026-07-17 @@ -77,12 +152,6 @@ placement and restore-previous behavior. Inventory, Character/Skills, and Spellbook/Components retain one position across switching, dragging, closing, and reopening. -**Acceptance:** Open Inventory, move it, then switch directly and after closing -to Character/Skills and Spellbook/Components. Every replacement must open at -the most recently moved main-panel position. Move another panel and repeat in -the opposite direction. Helpful/Harmful effects must not overwrite that -position. - --- ## #220 — Local player glides before locomotion animation starts diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 1a6d25bf..e0ff8525 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -50,7 +50,7 @@ accepted-divergence entries (#96, #49, #50). | IA-9 | One unified camera matrix for terrain — retail's separate `LScape::update_viewpoint` landscape viewpoint does not exist | `src/AcDream.App/Rendering/TerrainModernRenderer.cs:266` | Phase W T4.2: with one matrix everywhere, viewpoint-desync bugs are unrepresentable — the unification IS the correctness argument | Anything retail derives from the landcell-relative viewpoint (float precision at extreme coords, viewpoint-keyed state) has no analogue; a future port expecting it silently reads the camera | `LScape::update_viewpoint`; `LScape::draw` 0x00506330 | | IA-10 | Transparent groups sorted back-to-front per GROUP by first-instance position (no within-group sort) vs retail per-poly BSP-order draw | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:1364` (comparer :1662) | One MDI call per pass requires group-granularity ordering; per-poly sorting is incompatible with instanced multi-draw; works when group instances are spatially coherent | Spatially spread or interleaved transparent groups composite in the wrong order — popping / wrong see-through layering as the camera moves | retail per-poly BSP-order transparent draw (D3DPolyRender / PView::DrawCells) | | IA-11 | Tier-1 cross-frame batch-classification cache for static entities (retail re-walks part arrays every frame) | `src/AcDream.App/Rendering/Wb/EntityClassificationCache.cs:12` | Issue #53 perf tier; invariants documented (keys = EntityId + OWNING-landblock hint post-**#119** fix `2163308`; invalidation at despawn/LB-unload; mutation audit 2026-05-10) | Key collision or missed invalidation serves one entity another's batches — session-sticky wrong meshes (the #119 broken-stairs/water-barrel symptom) | retail per-frame part-array classification (no cache) | -| IA-12 | UI toolkit mirrors retail behavior from research docs, not a byte-port — keystone.dll is outside decomp coverage; observed constants embedded (drag 3 px, tooltip 1000 ms). `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-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/` | diff --git a/docs/research/2026-07-17-retail-shared-main-panel-pseudocode.md b/docs/research/2026-07-17-retail-shared-main-panel-pseudocode.md index a3e05b47..d71b51f2 100644 --- a/docs/research/2026-07-17-retail-shared-main-panel-pseudocode.md +++ b/docs/research/2026-07-17-retail-shared-main-panel-pseudocode.md @@ -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 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 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, restore-previous effects, persistence restore, and direct window dragging all therefore pass through the same owner. Panel-authored size/resize policy stays -with the active child; only the retail parent placement is shared. +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 reference contains no `gmPanelUI` port, so neither supplies a competing diff --git a/docs/research/2026-07-17-retail-window-control-cursor-pseudocode.md b/docs/research/2026-07-17-retail-window-control-cursor-pseudocode.md new file mode 100644 index 00000000..929c0923 --- /dev/null +++ b/docs/research/2026-07-17-retail-window-control-cursor-pseudocode.md @@ -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. diff --git a/src/AcDream.App/UI/CursorFeedbackController.cs b/src/AcDream.App/UI/CursorFeedbackController.cs index bd10f0d0..d1deb4f3 100644 --- a/src/AcDream.App/UI/CursorFeedbackController.cs +++ b/src/AcDream.App/UI/CursorFeedbackController.cs @@ -140,7 +140,9 @@ public sealed class CursorFeedbackController CombatMode: _combatModeProvider()); 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; } @@ -151,7 +153,13 @@ public sealed class CursorFeedbackController } 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) { @@ -310,6 +318,34 @@ public sealed class CursorFeedbackController 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) { while (element is not null) diff --git a/src/AcDream.App/UI/Layout/RetailWindowFrame.cs b/src/AcDream.App/UI/Layout/RetailWindowFrame.cs index b9d38594..aabae4e0 100644 --- a/src/AcDream.App/UI/Layout/RetailWindowFrame.cs +++ b/src/AcDream.App/UI/Layout/RetailWindowFrame.cs @@ -58,6 +58,7 @@ public static class RetailWindowFrame public ResizeEdges ResizableEdges { get; init; } = ResizeEdges.Left | ResizeEdges.Right | ResizeEdges.Top | ResizeEdges.Bottom; public bool ConstrainDragToParent { get; init; } + public bool ConstrainResizeToParent { get; init; } public float Opacity { get; init; } = 1f; public bool Visible { get; init; } = true; @@ -135,6 +136,7 @@ public static class RetailWindowFrame outerFrame.ResizeY = options.ResizeY; outerFrame.ResizableEdges = options.ResizableEdges; outerFrame.ConstrainDragToParent = options.ConstrainDragToParent; + outerFrame.ConstrainResizeToParent = options.ConstrainResizeToParent; outerFrame.Opacity = Math.Clamp(options.Opacity, 0f, 1f); outerFrame.Visible = options.Visible; diff --git a/src/AcDream.App/UI/RetailCursorCatalog.cs b/src/AcDream.App/UI/RetailCursorCatalog.cs index 4d6b62d2..647375dd 100644 --- a/src/AcDream.App/UI/RetailCursorCatalog.cs +++ b/src/AcDream.App/UI/RetailCursorCatalog.cs @@ -1,6 +1,11 @@ namespace AcDream.App.UI; -/// Retail global cursor enum metadata used outside LayoutDesc widget states. +/// +/// 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. +/// internal static class RetailCursorCatalog { public const uint CursorEnumTable = 6; @@ -32,6 +37,31 @@ internal static class RetailCursorCatalog return spec.IsValid; } + + /// + /// Direct MD_Data_Cursor 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. + /// + 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) diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 0d3f7995..cf3e3450 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -546,6 +546,10 @@ public sealed class RetailUiRuntime : IDisposable MinWidth = 200f, ResizeX = 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, Controller = controller, StateController = controller, @@ -798,10 +802,12 @@ public sealed class RetailUiRuntime : IDisposable Left = 18f, Top = 18f, Visible = false, - Resizable = false, ResizeX = false, - ResizeY = false, + ResizeY = true, + ResizableEdges = ResizeEdges.Bottom, ConstrainDragToParent = true, + ConstrainResizeToParent = true, + ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom, ContentClickThrough = false, Controller = controller, }); @@ -1147,10 +1153,10 @@ public sealed class RetailUiRuntime : IDisposable Chrome = RetailWindowChrome.NineSlice, Left = 540f, Top = 18f, - MaxHeight = 760f, ResizeX = false, ResizeY = true, ResizableEdges = ResizeEdges.Bottom, + ConstrainResizeToParent = true, Visible = false, ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom, ContentClickThrough = false, @@ -1233,11 +1239,7 @@ public sealed class RetailUiRuntime : IDisposable ResizeX = false, ResizeY = true, ResizableEdges = ResizeEdges.Bottom, - // The old 560px cap left a previously expanded window permanently - // 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), + ConstrainResizeToParent = true, ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom, }); _panelUi.RegisterMainPanel( diff --git a/src/AcDream.App/UI/RetailWindowManager.cs b/src/AcDream.App/UI/RetailWindowManager.cs index ae3399e2..f5081027 100644 --- a/src/AcDream.App/UI/RetailWindowManager.cs +++ b/src/AcDream.App/UI/RetailWindowManager.cs @@ -155,8 +155,15 @@ public sealed class RetailWindowManager : IDisposable var frame = handle.OuterFrame; if (!frame.ResizeX) width = frame.Width; if (!frame.ResizeY) height = frame.Height; - width = Math.Clamp(width, frame.MinWidth, frame.MaxWidth); - height = Math.Clamp(height, frame.MinHeight, frame.MaxHeight); + float maxWidth = frame.MaxWidth; + 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; frame.Width = width; frame.Height = height; diff --git a/src/AcDream.App/UI/UiElement.cs b/src/AcDream.App/UI/UiElement.cs index 1b4317ed..b4b5e214 100644 --- a/src/AcDream.App/UI/UiElement.cs +++ b/src/AcDream.App/UI/UiElement.cs @@ -188,6 +188,13 @@ public abstract class UiElement /// gmRadarUI::MoveTo enables this; most windows retain the toolkit default. public bool ConstrainDragToParent { get; set; } + /// + /// Clamp resize growth to the current parent extent. This is distinct from + /// : retail's shared main-panel host may be + /// moved freely, but its bottom resizebar grows only to the desktop edge. + /// + public bool ConstrainResizeToParent { get; set; } + /// If true, a left-drag starting near this element's edge/corner /// resizes it (window resize). Intended for top-level panels. public bool Resizable { get; set; } diff --git a/src/AcDream.App/UI/UiRoot.cs b/src/AcDream.App/UI/UiRoot.cs index ea7dc87e..c1d67887 100644 --- a/src/AcDream.App/UI/UiRoot.cs +++ b/src/AcDream.App/UI/UiRoot.cs @@ -341,11 +341,31 @@ public sealed class UiRoot : UiElement // Window resize takes precedence over move / drag-drop / hover. 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( _resizeStartX, _resizeStartY, _resizeStartW, _resizeStartH, _resizeEdges, x - _resizeMouseX, y - _resizeMouseY, _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.Width = nw; _resizeTarget.Height = nh; return; diff --git a/tests/AcDream.App.Tests/UI/CursorFeedbackControllerTests.cs b/tests/AcDream.App.Tests/UI/CursorFeedbackControllerTests.cs index e01231cc..557bf6ce 100644 --- a/tests/AcDream.App.Tests/UI/CursorFeedbackControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/CursorFeedbackControllerTests.cs @@ -32,17 +32,25 @@ public sealed class CursorFeedbackControllerTests } [Theory] - [InlineData(ResizeEdges.Right, CursorFeedbackKind.ResizeHorizontal)] - [InlineData(ResizeEdges.Bottom, CursorFeedbackKind.ResizeVertical)] - [InlineData(ResizeEdges.Right | ResizeEdges.Bottom, CursorFeedbackKind.ResizeDiagonalNwse)] - [InlineData(ResizeEdges.Left | ResizeEdges.Bottom, CursorFeedbackKind.ResizeDiagonalNesw)] - public void ResizeEdges_chooseExpectedCursor(ResizeEdges edges, CursorFeedbackKind expected) + [InlineData(ResizeEdges.Left, CursorFeedbackKind.ResizeHorizontal, 0x06006128u)] + [InlineData(ResizeEdges.Right, CursorFeedbackKind.ResizeHorizontal, 0x06006128u)] + [InlineData(ResizeEdges.Top, CursorFeedbackKind.ResizeVertical, 0x06005E66u)] + [InlineData(ResizeEdges.Bottom, CursorFeedbackKind.ResizeVertical, 0x06005E66u)] + [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 feedback = c.Resolve(new CursorFeedbackSnapshot(HoverResizeEdges: edges)); Assert.Equal(expected, feedback.Kind); + Assert.Equal(new UiCursorMedia(expectedDid, 16, 16), feedback.Cursor); } [Fact] @@ -52,10 +60,93 @@ public sealed class CursorFeedbackControllerTests Assert.Equal(CursorFeedbackKind.WindowMove, 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, 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 { [""] = 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 { [""] = 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] public void TargetMode_reportsValidInvalidAndPendingTargets() { diff --git a/tests/AcDream.App.Tests/UI/Layout/RetailWindowFrameTests.cs b/tests/AcDream.App.Tests/UI/Layout/RetailWindowFrameTests.cs index 06991d0a..ee204abd 100644 --- a/tests/AcDream.App.Tests/UI/Layout/RetailWindowFrameTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/RetailWindowFrameTests.cs @@ -93,6 +93,7 @@ public sealed class RetailWindowFrameTests ResizeX = false, ResizeY = true, ResizableEdges = ResizeEdges.Bottom, + ConstrainResizeToParent = true, ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right, ContentClickThrough = true, }); @@ -101,6 +102,7 @@ public sealed class RetailWindowFrameTests Assert.False(frame.ResizeX); Assert.True(frame.ResizeY); Assert.Equal(ResizeEdges.Bottom, frame.ResizableEdges); + Assert.True(frame.ConstrainResizeToParent); Assert.True(content.ClickThrough); Assert.False(content.Draggable); Assert.False(content.Resizable); diff --git a/tests/AcDream.App.Tests/UI/RetailCursorCatalogTests.cs b/tests/AcDream.App.Tests/UI/RetailCursorCatalogTests.cs index 661bcc14..8a34765b 100644 --- a/tests/AcDream.App.Tests/UI/RetailCursorCatalogTests.cs +++ b/tests/AcDream.App.Tests/UI/RetailCursorCatalogTests.cs @@ -1,6 +1,7 @@ using AcDream.App.Rendering; using AcDream.App.UI; using DatReaderWriter; +using DatReaderWriter.DBObjs; using DatReaderWriter.Options; using SysEnv = System.Environment; @@ -8,6 +9,47 @@ namespace AcDream.App.Tests.UI; 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(cursor.File, out var surface), kind.ToString()); + Assert.NotNull(surface); + Assert.Equal(32, surface.Width); + Assert.Equal(32, surface.Height); + } + } + [Theory] [InlineData(RetailGlobalCursorKind.Default, 0x01u, 0, 0)] [InlineData(RetailGlobalCursorKind.DefaultFound, 0x02u, 0, 0)] diff --git a/tests/AcDream.App.Tests/UI/RetailWindowManagerTests.cs b/tests/AcDream.App.Tests/UI/RetailWindowManagerTests.cs index a2d93a05..9c538bc2 100644 --- a/tests/AcDream.App.Tests/UI/RetailWindowManagerTests.cs +++ b/tests/AcDream.App.Tests/UI/RetailWindowManagerTests.cs @@ -118,6 +118,30 @@ public sealed class RetailWindowManagerTests 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] public void FocusAndCaptureTransitions_AreScopedToOwningHandle() { diff --git a/tests/AcDream.App.Tests/UI/UiRootInputTests.cs b/tests/AcDream.App.Tests/UI/UiRootInputTests.cs index e4451ece..ceca3432 100644 --- a/tests/AcDream.App.Tests/UI/UiRootInputTests.cs +++ b/tests/AcDream.App.Tests/UI/UiRootInputTests.cs @@ -349,6 +349,56 @@ public class UiRootInputTests 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] public void ResizeRect_RightBottom_GrowsSizeOnly() {