Opus dual-lens review of CT6 (ec50455a) found 1 blocker, 4 should-fix, 5
notes. All applied:
BLOCKER B1 — the shared gmPanelUI host (0x100005FE) IS retail's own
outer window frame, not a content element: its authored 310/372/310/1000
already include the 5px bevel on every side. RetailWindowFrame.Mount was
adding the NineSlice wrapper's OWN 10px chrome inset on top of that
already-chrome-inclusive source, clamping MinWidth to 320 while the
window's actual mounted outer width stayed 310 — silently below its own
minimum until RetailWindowManager.ResizeTo forcibly widened it despite
ResizeX=false. Fixed with a new
RetailWindowFrame.Options.DatConstraintSourceIsOuterFrame opt-out
(chrome inset = 0 for constraint resolution only, value stays
DAT-sourced); MountCharacter sets it true. Mounted clamp is now exactly
the host's four raw values: width fixed 310, height 372..1000. Added a
mount-time invariant (throws if the mounted outer extent falls outside
its own just-computed clamp) that would have caught this at the first
test run.
S4 (campaign-lead ruling) — the window must MOUNT at retail's authored
default, outer 372 (content 362, matching the host's own content parent
0x10000180), not 0x2100002E's own 300x600 content-authoring canvas
(which produced a stale 610px default pre-fix: 600 + 10 chrome inset).
372 is exactly the host's own authored MinHeight — retail opens at its
resize floor and can only be dragged taller. MountCharacter now sets
ContentHeight=362f explicitly. At this default the 9 attribute/vital
rows (180px) overflow the 160px list immediately — retail-correct, not
a regression.
S2 — 0x1000023E and 0x10000533 both author property 0x79
(HideWhenDisabled) TRUE (fixture-verified: BoolValue=true on both). A
fitting list HIDES the scrollbar entirely; it does not draw a full-track
"disabled" thumb. The code was already correct; four wrong descriptions
(plan ledger, CharacterStatController comment, CT7 script, test comment)
are corrected, plus a new IsPresentationVisible assertion pair in the
resize test.
S3 — CharacterTitlesController's `if (listBox.LayoutPolicy is null)`
Anchors fallback was unreachable on both the real DAT and the fixture
(0x10000532/0x10000539 both author HasOriginalParentSize=true, so
LayoutPolicy is always assigned). Deleted; added an InstalledDatFact pin
guarding the deletion against DAT drift.
N4 — renamed NineSlice_ChatShapedConstraints_... to
NineSlice_ContentShapedConstraints_InsetArithmeticClampsProgrammaticResize
(it tested inset arithmetic on a content-shaped source, not chat's real
contract) and added a true chat-contract pin mounting Chrome=Imported
with chat's real 300/100/2000/2000 constraints, asserting no inset
applies.
N5 — corrected the "nothing inferred, no register row" sentences in the
ground-truth doc and plan ledger: they were false pre-fix (the mounted
clamp WAS an inferred double-counted composition); true now that B1
removes the composition.
CT7 script §4 rewritten with exact clamps (no "≈"), the corrected
default-overflow scrollbar behavior, and an absolute starting-height
statement.
Verified: full hermetic solution suite green (15,441 tests, Release,
Lane exclusions per the release gate), InstalledDat lane green across
the whole solution (414 tests, ACDREAM_RUN_INSTALLED_DAT_TESTS=1,
Status!=KnownFailure) including two new pins
(TitlesListAndPage_AuthorHasOriginalParentSize,
Imported_ChatContract_ClampsAtAuthoredBoundsWithNoChromeInset).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
243 lines
11 KiB
C#
243 lines
11 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
|
|
/// UNLESS <see cref="DatConstraintSourceIsOuterFrame"/> is set.
|
|
/// </summary>
|
|
public ElementInfo? DatConstraintSource { get; init; }
|
|
|
|
/// <summary>
|
|
/// True when <see cref="DatConstraintSource"/> is itself retail's own
|
|
/// OUTER window frame — chrome already included in its authored
|
|
/// 0x3C..0x3F values — rather than a content element the wrapper's own
|
|
/// chrome gets layered on top of. CT6 fix-round BLOCKER B1: the
|
|
/// Character window's resize clamp is authored on the shared
|
|
/// <c>gmPanelUI</c> host (<c>0x100005FE</c>, LayoutDesc
|
|
/// <c>0x2100006E</c>) — that host IS retail's outer frame (5px bevel +
|
|
/// 300x362 content + 5px = 310x372), so its authored MinWidth/MinHeight/
|
|
/// MaxWidth/MaxHeight ALREADY include the chrome our NineSlice wrapper
|
|
/// draws. Adding the wrapper's own chrome inset on top double-counts
|
|
/// the bevel and clamps the mounted window BELOW its own authored
|
|
/// minimum. Set true only when the DAT source element genuinely already
|
|
/// represents the complete outer frame, not a content root — the
|
|
/// default (false) preserves the normal content-plus-chrome behavior
|
|
/// every other window relies on.
|
|
/// </summary>
|
|
public bool DatConstraintSourceIsOuterFrame { 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 DrawChromeCenter { get; init; } = true;
|
|
public bool Visible { get; init; } = true;
|
|
|
|
/// <summary>
|
|
/// Increment when the authored default extent changes incompatibly.
|
|
/// Persistence preserves position and window state while resetting stale
|
|
/// saved dimensions to this mount's current DAT-authored extent.
|
|
/// </summary>
|
|
public int AuthoredGeometryRevision { get; init; }
|
|
|
|
/// <summary>
|
|
/// Top-level desktop anchors. Most movable windows are unanchored; fixed
|
|
/// retail HUD roots can preserve their authored screen-edge margins.
|
|
/// </summary>
|
|
public AnchorEdges OuterAnchors { get; init; } = AnchorEdges.None;
|
|
|
|
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)),
|
|
};
|
|
|
|
if (outerFrame is UiNineSlicePanel nineSlice)
|
|
nineSlice.DrawCenterFill = options.DrawChromeCenter;
|
|
|
|
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 = options.OuterAnchors;
|
|
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;
|
|
|
|
int constraintInset = options.DatConstraintSourceIsOuterFrame ? 0 : inset;
|
|
outerFrame.MinWidth = ResolveConstraint(
|
|
options.MinWidth, options.DatConstraintSource, 0x3Fu, outerWidth, constraintInset);
|
|
outerFrame.MinHeight = ResolveConstraint(
|
|
options.MinHeight, options.DatConstraintSource, 0x3Eu, outerHeight, constraintInset);
|
|
outerFrame.MaxWidth = Math.Max(
|
|
outerFrame.MinWidth,
|
|
ResolveConstraint(options.MaxWidth, options.DatConstraintSource, 0x3Du, float.MaxValue, constraintInset));
|
|
outerFrame.MaxHeight = Math.Max(
|
|
outerFrame.MinHeight,
|
|
ResolveConstraint(options.MaxHeight, options.DatConstraintSource, 0x3Cu, float.MaxValue, constraintInset));
|
|
|
|
// Mount-time invariant (CT6 fix-round BLOCKER B1): a window must never
|
|
// open already violating its own clamp. This would have caught B1 at
|
|
// the first test run — the pre-fix Character mount computed outer
|
|
// bounds of 320/382/1010 (300x362 content + 10px chrome inset added
|
|
// TWICE) while its own MinWidth clamped to 320 but its actual outer
|
|
// WIDTH was only 310, silently sitting below its own minimum until
|
|
// RetailWindowManager.ResizeTo forcibly widened it despite ResizeX
|
|
// being false.
|
|
if (outerWidth < outerFrame.MinWidth || outerWidth > outerFrame.MaxWidth)
|
|
throw new InvalidOperationException(
|
|
$"RetailWindowFrame.Mount(\"{options.WindowName}\"): mounted outer width " +
|
|
$"{outerWidth} is outside its own clamp [{outerFrame.MinWidth}, {outerFrame.MaxWidth}] " +
|
|
"— the window would open already violating its authored resize bounds.");
|
|
if (outerHeight < outerFrame.MinHeight || outerHeight > outerFrame.MaxHeight)
|
|
throw new InvalidOperationException(
|
|
$"RetailWindowFrame.Mount(\"{options.WindowName}\"): mounted outer height " +
|
|
$"{outerHeight} is outside its own clamp [{outerFrame.MinHeight}, {outerFrame.MaxHeight}] " +
|
|
"— the window would open already violating its authored resize bounds.");
|
|
|
|
// 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,
|
|
options.AuthoredGeometryRevision);
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|