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:
Erik 2026-07-17 09:40:08 +02:00
parent 3f3cfdac30
commit 06016014bc
16 changed files with 578 additions and 27 deletions

View file

@ -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

View file

@ -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/` |

View file

@ -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

View file

@ -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.

View file

@ -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)

View file

@ -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;

View file

@ -1,6 +1,11 @@
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
{
public const uint CursorEnumTable = 6;
@ -32,6 +37,31 @@ internal static class RetailCursorCatalog
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)

View file

@ -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(

View file

@ -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;

View file

@ -188,6 +188,13 @@ public abstract class UiElement
/// <c>gmRadarUI::MoveTo</c> enables this; most windows retain the toolkit default.</summary>
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
/// resizes it (window resize). Intended for top-level panels.</summary>
public bool Resizable { get; set; }

View file

@ -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;

View file

@ -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<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]
public void TargetMode_reportsValidInvalidAndPendingTargets()
{

View file

@ -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);

View file

@ -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<RenderSurface>(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)]

View file

@ -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()
{

View file

@ -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()
{