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

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