acdream/src/AcDream.App/UI/Layout/RetailWindowFrame.cs
Erik 06016014bc 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.
2026-07-17 09:40:08 +02:00

184 lines
7.5 KiB
C#

using System;
namespace AcDream.App.UI.Layout;
/// <summary>How a production retained window obtains its outer chrome.</summary>
public enum RetailWindowChrome
{
/// <summary>The imported root already is the complete retail outer frame.</summary>
Imported,
/// <summary>Wrap imported content in the shared retail nine-slice frame.</summary>
NineSlice,
/// <summary>Use the shared frame variant with the toolbar's two height stops.</summary>
CollapsibleNineSlice,
}
/// <summary>
/// The single mount path for production retained windows. It owns the distinction
/// between DAT roots that already contain chrome and content roots that require the
/// shared nine-slice wrapper, applies exact resize/opacity/visibility policy, mounts
/// the outer frame, and returns its registered typed handle.
/// </summary>
public static class RetailWindowFrame
{
public sealed record Options
{
public required string WindowName { get; init; }
public RetailWindowChrome Chrome { get; init; } = RetailWindowChrome.NineSlice;
/// <summary>Initial outer-frame position in screen pixels.</summary>
public float Left { get; init; }
public float Top { get; init; }
/// <summary>
/// Optional mounted content extent. Used when a LayoutDesc root shares a
/// larger auxiliary coordinate space (the main chat root is cropped to 490px).
/// </summary>
public float? ContentWidth { get; init; }
public float? ContentHeight { get; init; }
public bool RebaseContentLayout { get; init; }
/// <summary>
/// Optional DAT element whose effective 0x3C..0x3F properties provide
/// max-height, max-width, min-height, and min-width respectively. Explicit
/// option values win; wrapper chrome is added to DAT content constraints.
/// </summary>
public ElementInfo? DatConstraintSource { get; init; }
public float? MinWidth { get; init; }
public float? MinHeight { get; init; }
public float? MaxWidth { get; init; }
public float? MaxHeight { get; init; }
public bool Draggable { get; init; } = true;
public bool Resizable { get; init; } = true;
public bool ResizeX { get; init; } = true;
public bool ResizeY { get; init; } = true;
public ResizeEdges ResizableEdges { get; init; } =
ResizeEdges.Left | ResizeEdges.Right | ResizeEdges.Top | ResizeEdges.Bottom;
public bool ConstrainDragToParent { get; init; }
public bool ConstrainResizeToParent { get; init; }
public float Opacity { get; init; } = 1f;
public bool Visible { get; init; } = true;
public AnchorEdges ContentAnchors { get; init; } =
AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom;
public bool? ContentClickThrough { get; init; }
public IRetainedPanelController? Controller { get; init; }
public IRetainedWindowStateController? StateController { get; init; }
}
public static RetailWindowHandle Mount(
UiRoot root,
UiElement content,
Func<uint, (uint handle, int w, int h)> resolveChrome,
Options options)
{
ArgumentNullException.ThrowIfNull(root);
ArgumentNullException.ThrowIfNull(content);
ArgumentNullException.ThrowIfNull(resolveChrome);
ArgumentNullException.ThrowIfNull(options);
ArgumentException.ThrowIfNullOrWhiteSpace(options.WindowName);
float contentWidth = options.ContentWidth ?? content.Width;
float contentHeight = options.ContentHeight ?? content.Height;
bool wrapped = options.Chrome != RetailWindowChrome.Imported;
int inset = wrapped ? 2 * RetailChromeSprites.Border : 0;
float outerWidth = contentWidth + inset;
float outerHeight = contentHeight + inset;
UiElement outerFrame;
if (wrapped)
{
outerFrame = options.Chrome switch
{
RetailWindowChrome.NineSlice => new UiNineSlicePanel(resolveChrome),
RetailWindowChrome.CollapsibleNineSlice => new UiCollapsibleFrame(resolveChrome),
_ => throw new ArgumentOutOfRangeException(nameof(options.Chrome)),
};
const int border = RetailChromeSprites.Border;
content.Left = border;
content.Top = border;
content.Width = contentWidth;
content.Height = contentHeight;
content.Anchors = options.ContentAnchors;
content.Draggable = false;
content.Resizable = false;
if (options.ContentClickThrough is { } clickThrough)
content.ClickThrough = clickThrough;
if (options.RebaseContentLayout)
content.RebaseChildLayoutBaselines();
outerFrame.AddChild(content);
}
else
{
outerFrame = content;
content.Width = contentWidth;
content.Height = contentHeight;
content.Anchors = AnchorEdges.None;
if (options.ContentClickThrough is { } clickThrough)
content.ClickThrough = clickThrough;
if (options.RebaseContentLayout)
content.RebaseChildLayoutBaselines();
}
outerFrame.Left = options.Left;
outerFrame.Top = options.Top;
outerFrame.Width = outerWidth;
outerFrame.Height = outerHeight;
outerFrame.Anchors = AnchorEdges.None;
outerFrame.Draggable = options.Draggable;
outerFrame.Resizable = options.Resizable;
outerFrame.ResizeX = options.ResizeX;
outerFrame.ResizeY = options.ResizeY;
outerFrame.ResizableEdges = options.ResizableEdges;
outerFrame.ConstrainDragToParent = options.ConstrainDragToParent;
outerFrame.ConstrainResizeToParent = options.ConstrainResizeToParent;
outerFrame.Opacity = Math.Clamp(options.Opacity, 0f, 1f);
outerFrame.Visible = options.Visible;
outerFrame.MinWidth = ResolveConstraint(
options.MinWidth, options.DatConstraintSource, 0x3Fu, outerWidth, inset);
outerFrame.MinHeight = ResolveConstraint(
options.MinHeight, options.DatConstraintSource, 0x3Eu, outerHeight, inset);
outerFrame.MaxWidth = Math.Max(
outerFrame.MinWidth,
ResolveConstraint(options.MaxWidth, options.DatConstraintSource, 0x3Du, float.MaxValue, inset));
outerFrame.MaxHeight = Math.Max(
outerFrame.MinHeight,
ResolveConstraint(options.MaxHeight, options.DatConstraintSource, 0x3Cu, float.MaxValue, inset));
// Capture the wrapper/content baseline at the mounted design extent now,
// so a resize that occurs before the first draw uses the same margins as a
// resize after rendering one frame.
if (wrapped)
content.ApplyAnchor(outerWidth, outerHeight);
root.AddChild(outerFrame);
return root.RegisterWindow(
options.WindowName,
outerFrame,
content,
options.Controller,
options.StateController);
}
private static float ResolveConstraint(
float? explicitValue,
ElementInfo? datSource,
uint propertyId,
float fallback,
int chromeInset)
{
if (explicitValue is { } value)
return value;
if (datSource is not null
&& datSource.TryGetEffectiveInteger(propertyId, out int datValue)
&& datValue >= 0)
return datValue + chromeInset;
return fallback;
}
}