diff --git a/ModernFormsNext.CodeGeneration/CSharp/CSharpDesignerGenerator.cs b/ModernFormsNext.CodeGeneration/CSharp/CSharpDesignerGenerator.cs index 7230472..48f00d0 100644 --- a/ModernFormsNext.CodeGeneration/CSharp/CSharpDesignerGenerator.cs +++ b/ModernFormsNext.CodeGeneration/CSharp/CSharpDesignerGenerator.cs @@ -151,7 +151,8 @@ public CSharpDesignerGenerationResult Generate( if (document.RootKind == DesignRootKind.Form) writer.WriteLine($" this.Text = {CSharpLiteralWriter.WriteStringLiteral(rootName)};"); - writer.WriteLine($" this.Size = new System.Drawing.Size({document.Size.Width}, {document.Size.Height});"); + var rootSizeProperty = document.RootKind == DesignRootKind.Form ? "ClientSize" : "Size"; + writer.WriteLine($" this.{rootSizeProperty} = new System.Drawing.Size({document.Size.Width}, {document.Size.Height});"); foreach (var property in document.Properties) { diff --git a/ModernFormsNext.CodeGeneration/Reverse/CSharpDesignerParser.cs b/ModernFormsNext.CodeGeneration/Reverse/CSharpDesignerParser.cs index 677af2f..1cdb9a8 100644 --- a/ModernFormsNext.CodeGeneration/Reverse/CSharpDesignerParser.cs +++ b/ModernFormsNext.CodeGeneration/Reverse/CSharpDesignerParser.cs @@ -652,9 +652,8 @@ private void ProcessFormPropertyAssignment( break; - // Size is the canonical generated contract. Keep accepting ClientSize from earlier - // designer builds so existing .Designer.cs files can still be imported and rewritten - // in the current canonical form. + // Form documents canonically use ClientSize while UserControl documents use Size. + // Accept both spellings so older generated files remain importable. case "Size": case "ClientSize": if (TryReadSize(valueExpression, out var width, out var height)) @@ -1052,6 +1051,17 @@ private static bool TryReadDouble(ExpressionSyntax expression, out double value) private static bool TryReadEnum(ExpressionSyntax expression, out string enumTypeName, out string memberName) { + if (expression is BinaryExpressionSyntax binary + && binary.IsKind(SyntaxKind.BitwiseOrExpression) + && TryReadEnum(binary.Left, out var leftTypeName, out var leftMemberName) + && TryReadEnum(binary.Right, out var rightTypeName, out var rightMemberName) + && string.Equals(leftTypeName, rightTypeName, StringComparison.Ordinal)) + { + enumTypeName = leftTypeName; + memberName = $"{leftMemberName}, {rightMemberName}"; + return true; + } + if (expression is MemberAccessExpressionSyntax memberAccess) { enumTypeName = memberAccess.Expression.ToString(); diff --git a/ModernFormsNext.Designer.Tests/CSharpDesignerRoundTripTests.cs b/ModernFormsNext.Designer.Tests/CSharpDesignerRoundTripTests.cs index 79d47c6..bb9f2bc 100644 --- a/ModernFormsNext.Designer.Tests/CSharpDesignerRoundTripTests.cs +++ b/ModernFormsNext.Designer.Tests/CSharpDesignerRoundTripTests.cs @@ -8,7 +8,7 @@ namespace ModernFormsNext.Designer.Tests; public sealed class CSharpDesignerRoundTripTests { [Fact] - public void GeneratorUsesSizeAndNeverEmitsClientSize() + public void FormGeneratorUsesClientSizeAndNeverEmitsOuterSize() { var document = CreateDocument(); document.Properties["Size"] = DesignPropertyValue.FromInt32(456); @@ -17,8 +17,8 @@ public void GeneratorUsesSizeAndNeverEmitsClientSize() var result = new CSharpDesignerGenerator().Generate(document); Assert.True(result.Succeeded, string.Join(Environment.NewLine, result.Validation.Errors)); - Assert.Contains("this.Size = new System.Drawing.Size(900, 600);", result.Code); - Assert.DoesNotContain("this.ClientSize =", result.Code); + Assert.Contains("this.ClientSize = new System.Drawing.Size(900, 600);", result.Code); + Assert.DoesNotContain("this.Size =", result.Code); } [Theory] @@ -61,8 +61,8 @@ public void RepeatedMfdesignAndCodeRoundTripsKeepLogicalGeometry() document = serializer.Deserialize(serializer.Serialize(document)); var generated = service.Generate(document); Assert.True(generated.Succeeded, string.Join(Environment.NewLine, generated.Validation.Errors)); - Assert.Contains("this.Size = new System.Drawing.Size(900, 600);", generated.Code); - Assert.DoesNotContain("this.ClientSize =", generated.Code); + Assert.Contains("this.ClientSize = new System.Drawing.Size(900, 600);", generated.Code); + Assert.DoesNotContain("this.Size =", generated.Code); var parsed = service.ParseDesignerCode(generated.Code); Assert.True(parsed.Success, string.Join(Environment.NewLine, parsed.Diagnostics.Select(diagnostic => diagnostic.Message))); diff --git a/ModernFormsNext.Designer.Tests/DesignerRuntimeLayoutParityHarness.cs b/ModernFormsNext.Designer.Tests/DesignerRuntimeLayoutParityHarness.cs new file mode 100644 index 0000000..4fe62e7 --- /dev/null +++ b/ModernFormsNext.Designer.Tests/DesignerRuntimeLayoutParityHarness.cs @@ -0,0 +1,566 @@ +using System.Drawing; +using System.Reflection; +using ModernFormsNext.Animations; +using ModernFormsNext.Designer.Properties; +using ModernFormsNext.Designer.Services; +using ModernFormsNext.Designer.Surface; +using ModernFormsNext.Designing; +using Xunit; + +namespace ModernFormsNext.Designer.Tests; + +public sealed record ParityScenario( + string Name, + Func CreateDocument, + DesignSize? LayoutSize = null) +{ + public override string ToString() => Name; +} + +internal sealed record LayoutNodeSnapshot( + string Path, + string TypeName, + Rectangle Bounds, + Rectangle? ClientRectangle, + Rectangle? DisplayRectangle, + Rectangle VisibleBounds, + Size? ChildAvailableSize); + +internal sealed record LayoutSnapshot( + string Scenario, + IReadOnlyDictionary Nodes); + +/// +/// Builds equivalent Designer and runtime control trees and compares normalized semantic geometry. +/// +/// +/// This helper deliberately delegates all placement to and the +/// production runtime . It only translates document properties, +/// collects public geometry, and normalizes absolute paths and ancestor clipping for diagnostics. +/// +internal static class DesignerRuntimeLayoutParityHarness +{ + public static void AssertParity(ParityScenario scenario) + { + var document = scenario.CreateDocument(); + AssertParity(scenario.Name, document, document, scenario.LayoutSize); + } + + public static void AssertParity( + string scenario, + DesignDocument designerDocument, + DesignDocument runtimeDocument, + DesignSize? layoutSize = null) + { + var targetSize = layoutSize ?? runtimeDocument.Size; + var designer = CaptureDesigner(scenario, designerDocument, targetSize); + using var runtimeTree = RuntimeLayoutTree.Build(runtimeDocument); + runtimeTree.Resize(targetSize); + var runtime = runtimeTree.Capture(scenario); + + AssertSnapshotsEqual(designer, runtime); + } + + public static void AssertParity( + string scenario, + DesignDocument designerDocument, + object initializedRuntimeRoot) + { + var designer = CaptureDesigner(scenario, designerDocument, designerDocument.Size); + using var runtimeTree = RuntimeLayoutTree.Attach(designerDocument, initializedRuntimeRoot); + runtimeTree.Resize(designerDocument.Size); + var runtime = runtimeTree.Capture(scenario); + + AssertSnapshotsEqual(designer, runtime); + } + + public static void AssertDpiParity(ParityScenario scenario, double dpiScale) + { + var document = scenario.CreateDocument(); + var targetSize = scenario.LayoutSize ?? document.Size; + var designer = CaptureDesigner(scenario.Name, document, targetSize); + using var runtimeTree = RuntimeLayoutTree.Build(document); + runtimeTree.Resize(targetSize); + var runtime = runtimeTree.Capture(scenario.Name); + var differences = new List(); + + AssertSnapshotsEqual(designer, runtime); + + foreach (var path in designer.Nodes.Keys.OrderBy(path => path, StringComparer.Ordinal)) + { + if (!runtime.Nodes.TryGetValue(path, out var runtimeNode)) + continue; + + var designerBounds = DesignerDpiCoordinateConverter.LogicalToDevice(designer.Nodes[path].Bounds, dpiScale); + var runtimeBounds = DesignerDpiCoordinateConverter.LogicalToDevice(runtimeNode.Bounds, dpiScale); + AddDifference(differences, scenario.Name, path, $"Bounds@{dpiScale:0.##}x", runtimeBounds, designerBounds); + } + + Assert.True(differences.Count == 0, FormatDifferences(differences)); + } + + public static LayoutSnapshot CaptureDesigner(string scenario, DesignDocument document, DesignSize size) + { + var layout = new DesignerLayoutEngine().Layout(document, size); + var nodes = new Dictionary(StringComparer.Ordinal); + var rootPath = RootPath(document); + var rootBounds = new DesignBounds(0, 0, size.Width, size.Height); + var rootDisplay = document.RootKind == DesignRootKind.UserControl + ? DesignerLayoutProperties.GetPaddedContentBounds( + rootBounds, + DesignerLayoutProperties.GetPadding(document.Properties)) + : rootBounds; + + nodes.Add( + rootPath, + new LayoutNodeSnapshot( + rootPath, + document.RootKind.ToString(), + ToRectangle(rootBounds), + ToRectangle(rootBounds), + ToRectangle(rootDisplay), + ToRectangle(rootBounds), + new Size(rootDisplay.Width, rootDisplay.Height))); + + CaptureDesignerChildren(document.Controls, rootPath, rootBounds, layout, nodes); + return new LayoutSnapshot(scenario, nodes); + } + + private static void CaptureDesignerChildren( + IEnumerable children, + string parentPath, + DesignBounds parentClip, + DesignerLayoutResult layout, + IDictionary nodes) + { + foreach (var child in children) + { + var path = $"{parentPath}.{child.Name}"; + var bounds = layout.GetEffectiveBounds(child); + var visibleBounds = IntersectRectangles(ToRectangle(bounds), ToRectangle(parentClip)); + var isContainer = IsContainer(child.TypeName); + var client = isContainer ? bounds : (DesignBounds?)null; + var display = isContainer + ? DesignerLayoutProperties.GetContainerContentBounds(child, bounds) + : (DesignBounds?)null; + + nodes.Add( + path, + new LayoutNodeSnapshot( + path, + NormalizeTypeName(child.TypeName), + ToRectangle(bounds), + client is { } clientBounds ? ToRectangle(clientBounds) : null, + display is { } displayBounds ? ToRectangle(displayBounds) : null, + visibleBounds, + display is { } available ? new Size(available.Width, available.Height) : null)); + + CaptureDesignerChildren( + child.Children, + path, + new DesignBounds(visibleBounds.X, visibleBounds.Y, visibleBounds.Width, visibleBounds.Height), + layout, + nodes); + } + } + + private static void AssertSnapshotsEqual(LayoutSnapshot designer, LayoutSnapshot runtime) + { + var differences = new List(); + var allPaths = designer.Nodes.Keys + .Concat(runtime.Nodes.Keys) + .Distinct(StringComparer.Ordinal) + .OrderBy(path => path, StringComparer.Ordinal); + + foreach (var path in allPaths) + { + if (!runtime.Nodes.TryGetValue(path, out var runtimeNode)) + { + differences.Add($"scenario={designer.Scenario}; node={path}; property=Hierarchy; runtime=; designer=present"); + continue; + } + + if (!designer.Nodes.TryGetValue(path, out var designerNode)) + { + differences.Add($"scenario={designer.Scenario}; node={path}; property=Hierarchy; runtime=present; designer="); + continue; + } + + AddDifference(differences, designer.Scenario, path, "Type", runtimeNode.TypeName, designerNode.TypeName); + AddDifference(differences, designer.Scenario, path, "Bounds", runtimeNode.Bounds, designerNode.Bounds); + AddDifference(differences, designer.Scenario, path, "ClientRectangle", runtimeNode.ClientRectangle, designerNode.ClientRectangle); + AddDifference(differences, designer.Scenario, path, "DisplayRectangle", runtimeNode.DisplayRectangle, designerNode.DisplayRectangle); + AddDifference(differences, designer.Scenario, path, "VisibleBounds", runtimeNode.VisibleBounds, designerNode.VisibleBounds); + AddDifference(differences, designer.Scenario, path, "ChildAvailableSize", runtimeNode.ChildAvailableSize, designerNode.ChildAvailableSize); + } + + Assert.True(differences.Count == 0, FormatDifferences(differences)); + } + + private static void AddDifference( + ICollection differences, + string scenario, + string path, + string property, + T runtime, + T designer) + { + if (!EqualityComparer.Default.Equals(runtime, designer)) + { + differences.Add( + $"scenario={scenario}; node={path}; property={property}; " + + $"runtime={FormatValue(runtime)}; designer={FormatValue(designer)}"); + } + } + + private static string FormatDifferences(IReadOnlyCollection differences) + => differences.Count == 0 + ? string.Empty + : "Designer/runtime layout parity failed:" + Environment.NewLine + string.Join(Environment.NewLine, differences); + + private static string FormatValue(T value) + => value switch + { + null => "", + Rectangle rectangle => $"X={rectangle.X},Y={rectangle.Y},Width={rectangle.Width},Height={rectangle.Height}", + Size size => $"Width={size.Width},Height={size.Height}", + _ => value.ToString() ?? "" + }; + + private static bool IsContainer(string typeName) + { + var normalized = NormalizeTypeName(typeName); + return normalized is "Panel" or "UserControl" or "ScrollableControl" or "FlowLayoutPanel" or "TableLayoutPanel"; + } + + private static string NormalizeTypeName(string typeName) + => DesignerProjectUserControlDiscovery.NormalizeTypeName(typeName).Split('.').Last(); + + private static string RootPath(DesignDocument document) + => string.IsNullOrWhiteSpace(document.FormName) ? document.ClassName : document.FormName; + + private static Rectangle ToRectangle(DesignBounds bounds) + => new(bounds.X, bounds.Y, bounds.Width, bounds.Height); + + private static Rectangle IntersectRectangles(Rectangle left, Rectangle right) + { + var x = Math.Max(left.Left, right.Left); + var y = Math.Max(left.Top, right.Top); + var width = Math.Max(0, Math.Min(left.Right, right.Right) - x); + var height = Math.Max(0, Math.Min(left.Bottom, right.Bottom) - y); + return new Rectangle(x, y, width, height); + } + + private sealed class RuntimeLayoutTree : IDisposable + { + private readonly DesignDocument document; + private readonly Dictionary controls; + private readonly Control? controlRoot; + private readonly Form? formRoot; + private readonly bool ownsRoot; + + private RuntimeLayoutTree( + DesignDocument document, + Dictionary controls, + Control? controlRoot, + Form? formRoot, + bool ownsRoot) + { + this.document = document; + this.controls = controls; + this.controlRoot = controlRoot; + this.formRoot = formRoot; + this.ownsRoot = ownsRoot; + } + + public static RuntimeLayoutTree Build(DesignDocument document) + { + var controls = new Dictionary(); + + if (document.RootKind == DesignRootKind.Form) + { + var form = new Form(); + ApplyRootProperties(form, document); + form.ClientSize = ToSize(document.Size); + CreateControls(document.Controls, controls); + AddChildren(form.Controls, document.Controls, controls, sequential: false); + var directChild = document.Controls.Select(node => controls[node]).FirstOrDefault(); + directChild?.Parent?.PerformLayout(); + foreach (var child in document.Controls) + PerformLayoutRecursively(controls[child]); + return new RuntimeLayoutTree(document, controls, controlRoot: null, form, ownsRoot: true); + } + + var root = new UserControl(); + root.Size = ToSize(document.Size); + ApplyRootProperties(root, document); + root.SuspendLayout(); + CreateControls(document.Controls, controls); + AddChildren(root.Controls, document.Controls, controls, sequential: false); + root.ResumeLayout(true); + PerformLayoutRecursively(root); + return new RuntimeLayoutTree(document, controls, root, formRoot: null, ownsRoot: true); + } + + public static RuntimeLayoutTree Attach(DesignDocument document, object initializedRoot) + { + var controls = new Dictionary(); + + switch (initializedRoot) + { + case Form form: + AttachChildren(document.Controls, form.Controls, controls); + return new RuntimeLayoutTree(document, controls, controlRoot: null, form, ownsRoot: false); + case UserControl control: + AttachChildren(document.Controls, control.Controls, controls); + return new RuntimeLayoutTree(document, controls, control, formRoot: null, ownsRoot: false); + default: + throw new InvalidOperationException($"Generated root type '{initializedRoot.GetType().FullName}' is not a Form or UserControl."); + } + } + + public void Resize(DesignSize size) + { + if (controlRoot is not null) + { + controlRoot.Size = ToSize(size); + PerformLayoutRecursively(controlRoot); + return; + } + + formRoot!.ClientSize = ToSize(size); + var directChild = document.Controls.Select(node => controls[node]).FirstOrDefault(); + directChild?.Parent?.PerformLayout(); + foreach (var child in document.Controls) + PerformLayoutRecursively(controls[child]); + } + + public LayoutSnapshot Capture(string scenario) + { + var nodes = new Dictionary(StringComparer.Ordinal); + var rootPath = RootPath(document); + var rootSize = controlRoot?.Size ?? formRoot!.ClientSize; + var rootBounds = new Rectangle(Point.Empty, rootSize); + var rootDisplay = controlRoot?.DisplayRectangle ?? rootBounds; + + nodes.Add( + rootPath, + new LayoutNodeSnapshot( + rootPath, + document.RootKind.ToString(), + rootBounds, + rootBounds, + rootDisplay, + rootBounds, + rootDisplay.Size)); + + CaptureRuntimeChildren(document.Controls, rootPath, Point.Empty, rootBounds, nodes); + return new LayoutSnapshot(scenario, nodes); + } + + public void Dispose() + { + if (!ownsRoot) + return; + + controlRoot?.Dispose(); + formRoot?.Dispose(); + } + + private void CaptureRuntimeChildren( + IEnumerable children, + string parentPath, + Point parentOffset, + Rectangle parentClip, + IDictionary snapshots) + { + foreach (var node in children) + { + var control = controls[node]; + var path = $"{parentPath}.{node.Name}"; + var bounds = control.Bounds; + bounds.Offset(parentOffset); + var visible = Intersect(bounds, parentClip); + var isContainer = IsContainer(node.TypeName); + Rectangle? client = null; + Rectangle? display = null; + + if (isContainer) + { + client = Offset(control.ClientRectangle, bounds.Location); + display = Offset(control.DisplayRectangle, bounds.Location); + } + + snapshots.Add( + path, + new LayoutNodeSnapshot( + path, + control.GetType().Name, + bounds, + client, + display, + visible, + display?.Size)); + + CaptureRuntimeChildren(node.Children, path, bounds.Location, visible, snapshots); + } + } + + private static void AddChildren( + Control.ControlCollection runtimeChildren, + IReadOnlyList designChildren, + IDictionary controls, + bool sequential) + { + var ordered = sequential + ? designChildren + : designChildren.Reverse().ToArray(); + + foreach (var child in ordered) + { + var control = controls[child]; + runtimeChildren.Add(control); + AddChildren( + control.Controls, + child.Children, + controls, + sequential: control is FlowLayoutPanel or TableLayoutPanel); + } + + if (runtimeChildren.Owner is TableLayoutPanel table) + { + foreach (var child in designChildren) + ApplyTablePlacement(table, controls[child], child); + } + } + + private static void CreateControls( + IEnumerable nodes, + IDictionary controls) + { + foreach (var node in nodes) + { + Control control = NormalizeTypeName(node.TypeName) switch + { + "Button" => new Button(), + "Ellipse" => new Ellipse(), + "FlowLayoutPanel" => new FlowLayoutPanel(), + "Label" => new Label(), + "ScrollableControl" => new ScrollableControl(), + "TableLayoutPanel" => new TableLayoutPanel(), + "TextBox" => new TextBox(), + "UserControl" => new UserControl(), + _ => new Panel() + }; + + control.Name = node.Name; + control.Bounds = ToRectangle(node.Bounds); + ApplyProperties(control, node.Properties); + controls.Add(node, control); + CreateControls(node.Children, controls); + } + } + + private static void ApplyRootProperties(object root, DesignDocument document) + => ApplyProperties(root, document.Properties); + + private static void ApplyProperties( + object target, + IReadOnlyDictionary properties) + { + foreach (var property in properties) + { + if (property.Key is "TableColumn" or "TableRow" or "TableColumnSpan" or "TableRowSpan") + continue; + + if (property.Key == LayoutTransitionDesignValue.PropertyName + && target is Control control + && LayoutTransitionDesignValue.TryRead( + property.Value, + out var enabled, + out var durationMilliseconds, + out _, + out _)) + { + control.LayoutTransition = new LayoutTransition + { + Enabled = enabled, + Duration = TimeSpan.FromMilliseconds(durationMilliseconds) + }; + continue; + } + + var runtimeProperty = target.GetType().GetProperty( + property.Key, + BindingFlags.Instance | BindingFlags.Public); + if (runtimeProperty?.SetMethod is not { IsPublic: true }) + continue; + + var value = DesignerPropertyValueEditor.FromDesignPropertyValue(property.Value, runtimeProperty.PropertyType); + runtimeProperty.SetValue(target, value); + } + } + + private static void ApplyTablePlacement(TableLayoutPanel table, Control control, DesignControlNode node) + { + if (TryGetInt(node, "TableColumn", out var column)) + table.SetColumn(control, column); + if (TryGetInt(node, "TableRow", out var row)) + table.SetRow(control, row); + if (TryGetInt(node, "TableColumnSpan", out var columnSpan)) + table.SetColumnSpan(control, columnSpan); + if (TryGetInt(node, "TableRowSpan", out var rowSpan)) + table.SetRowSpan(control, rowSpan); + } + + private static bool TryGetInt(DesignControlNode node, string name, out int value) + { + value = 0; + if (!node.Properties.TryGetValue(name, out var property)) + return false; + + value = Convert.ToInt32(property.Value, System.Globalization.CultureInfo.InvariantCulture); + return true; + } + + private static void AttachChildren( + IReadOnlyList designChildren, + Control.ControlCollection runtimeChildren, + IDictionary controls) + { + foreach (var node in designChildren) + { + var control = runtimeChildren.Single(candidate => string.Equals(candidate.Name, node.Name, StringComparison.Ordinal)); + controls.Add(node, control); + AttachChildren(node.Children, control.Controls, controls); + } + } + + private static void PerformLayoutRecursively(Control control) + { + control.PerformLayout(); + foreach (var child in control.Controls) + PerformLayoutRecursively(child); + } + + private static Rectangle Offset(Rectangle rectangle, Point offset) + { + rectangle.Offset(offset); + return rectangle; + } + + private static Rectangle Intersect(Rectangle first, Rectangle second) + { + var left = Math.Max(first.Left, second.Left); + var top = Math.Max(first.Top, second.Top); + var right = Math.Min(first.Right, second.Right); + var bottom = Math.Min(first.Bottom, second.Bottom); + return right <= left || bottom <= top + ? new Rectangle(left, top, 0, 0) + : Rectangle.FromLTRB(left, top, right, bottom); + } + + private static Size ToSize(DesignSize size) + => new(size.Width, size.Height); + } +} diff --git a/ModernFormsNext.Designer.Tests/DesignerRuntimeLayoutParityTests.cs b/ModernFormsNext.Designer.Tests/DesignerRuntimeLayoutParityTests.cs new file mode 100644 index 0000000..4d6d248 --- /dev/null +++ b/ModernFormsNext.Designer.Tests/DesignerRuntimeLayoutParityTests.cs @@ -0,0 +1,616 @@ +using System.Drawing; +using System.Reflection; +using System.Runtime.Loader; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using ModernFormsNext.CodeGeneration.CSharp; +using ModernFormsNext.CodeGeneration.Reverse; +using ModernFormsNext.Designer.Properties; +using ModernFormsNext.Designer.Services; +using ModernFormsNext.Designer.Surface; +using ModernFormsNext.Designing; +using Xunit; + +namespace ModernFormsNext.Designer.Tests; + +public sealed class DesignerRuntimeLayoutParityTests +{ + private static int nextArtifactId; + + [Theory] + [MemberData(nameof(CoreParityScenarios))] + public void CoreScenarioMatchesProductionRuntimeLayout(ParityScenario scenario) + => DesignerRuntimeLayoutParityHarness.AssertParity(scenario); + + [Theory] + [InlineData(1d)] + [InlineData(1.25d)] + [InlineData(1.5d)] + [InlineData(2d)] + public void LogicalParityKeepsIdenticalDeviceEdgesAtSupportedDpiScales(double dpiScale) + { + var scenario = new ParityScenario( + "dpi-asymmetric-padding-top-fill", + () => DockDocument([DockStyle.Top, DockStyle.Fill], new Padding(7, 11, 13, 17))); + + DesignerRuntimeLayoutParityHarness.AssertDpiParity(scenario, dpiScale); + } + + [Theory] + [MemberData(nameof(PropertyGridLiveEdits))] + public void PropertyGridLiveEditRelayoutsLikeRuntime(string propertyName, string value, DesignSize? layoutSize) + { + var document = BasicDocument(); + var child = Node("child", "Panel", 20, 30, 80, 50); + document.Controls.Add(child); + var session = new DesignerSession(); + session.LoadDocument(document); + session.SelectNode(child); + var propertyGrid = new DesignerPropertyGridState(session); + var property = Assert.Single(propertyGrid.Properties, candidate => candidate.Name == propertyName); + propertyGrid.SelectRow(new DesignerPropertyGridRow(property)); + + Assert.True(propertyGrid.CommitSelectedValue(value)); + DesignerRuntimeLayoutParityHarness.AssertParity( + $"property-grid-{propertyName}", + document, + document, + layoutSize); + } + + [Fact] + public void RootPropertyGridResizePersistsAnchoredGeometryWithoutDrift() + { + var document = BasicDocument(); + var child = Node("anchored", "Panel", 200, 130, 80, 50); + child.Properties["Anchor"] = EnumValue(AnchorStyles.Right | AnchorStyles.Bottom); + document.Controls.Add(child); + var session = new DesignerSession(); + session.LoadDocument(document); + session.SelectNode(null); + var propertyGrid = new DesignerPropertyGridState(session); + + Commit(propertyGrid, "Width", "420"); + Commit(propertyGrid, "Height", "310"); + DesignerRuntimeLayoutParityHarness.AssertParity("root-resize-grow", document, document); + + Commit(propertyGrid, "Width", "300"); + Commit(propertyGrid, "Height", "200"); + DesignerRuntimeLayoutParityHarness.AssertParity("root-resize-return", document, document); + + Assert.Equal(new DesignBounds(200, 130, 80, 50), child.Bounds); + } + + [Theory] + [MemberData(nameof(RoundTripScenarios))] + public void SaveAndReopenMfdesignPreservesParity(ParityScenario scenario) + { + var original = scenario.CreateDocument(); + var artifactId = Interlocked.Increment(ref nextArtifactId); + var path = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), + $"ModernFormsNext-layout-parity-{Environment.ProcessId}-{artifactId}.mfdesign"); + + try + { + DesignDocumentSerializer.Default.Save(path, original); + var reopened = DesignDocumentSerializer.Default.Load(path); + DesignerRuntimeLayoutParityHarness.AssertParity( + $"save-reopen-{scenario.Name}", + reopened, + original, + scenario.LayoutSize); + } + finally + { + File.Delete(path); + } + } + + [Theory] + [MemberData(nameof(CodeGenerationScenarios))] + public void GeneratedDesignerCodeBuildsAndRunsWithParity(ParityScenario scenario) + { + var document = scenario.CreateDocument(); + using var generatedRoot = CompileAndCreateGeneratedRoot(document); + + DesignerRuntimeLayoutParityHarness.AssertParity( + $"codegen-{scenario.Name}", + document, + generatedRoot.Instance); + } + + [Theory] + [MemberData(nameof(ReverseParserScenarios))] + public void ReverseParserReconstructsDesignerGeometryEquivalentToRuntime(ParityScenario scenario) + { + var runtimeDocument = scenario.CreateDocument(); + var generated = new CSharpDesignerGenerator().Generate(runtimeDocument); + Assert.True(generated.Succeeded, string.Join(Environment.NewLine, generated.Validation.Errors)); + var parsed = new CSharpDesignerParser().Parse( + generated.Code, + new CSharpDesignerParseOptions + { + RootKind = runtimeDocument.RootKind, + NamespaceOverride = runtimeDocument.Namespace, + ClassNameOverride = runtimeDocument.ClassName + }); + + Assert.True(parsed.Success, string.Join(Environment.NewLine, parsed.Diagnostics.Select(diagnostic => diagnostic.Message))); + DesignerRuntimeLayoutParityHarness.AssertParity( + $"reverse-parser-{scenario.Name}", + Assert.IsType(parsed.Document), + runtimeDocument, + scenario.LayoutSize); + } + + [Fact] + public void HitTestingUsesFinalVisibleClippedParityGeometry() + { + var document = BasicDocument(); + var panel = Node("panel", "Panel", 20, 20, 140, 100); + panel.Properties["Padding"] = PaddingValue(new Padding(10)); + var child = Node("child", "Panel", 0, 0, 40, 30, DockStyle.Fill); + panel.Children.Add(child); + document.Controls.Add(panel); + var session = new DesignerSession(); + session.LoadDocument(document); + var hitTest = new DesignerHitTestService(new DesignerCoordinateMapper()); + + Assert.Same(panel, hitTest.HitTestControl(session, new DesignPoint(25, 25)).Node); + Assert.Same(child, hitTest.HitTestControl(session, new DesignPoint(31, 31)).Node); + Assert.Same(panel, hitTest.HitTestControl(session, new DesignPoint(159, 119)).Node); + Assert.Null(hitTest.HitTestControl(session, new DesignPoint(160, 120)).Node); + + child.Properties["Visible"] = DesignPropertyValue.FromBoolean(false); + session.NotifyDocumentChanged(); + Assert.Same(panel, hitTest.HitTestControl(session, new DesignPoint(31, 31)).Node); + } + + [Fact] + public void ChildReorderRemoveAndAddStayEquivalentToRuntimeOrder() + { + var document = DockDocument([DockStyle.Top, DockStyle.Top, DockStyle.Fill]); + DesignerRuntimeLayoutParityHarness.AssertParity("order-initial", document, document); + + var moved = document.Controls[0]; + document.Controls.RemoveAt(0); + document.Controls.Insert(1, moved); + DesignerRuntimeLayoutParityHarness.AssertParity("order-reordered", document, document); + + var removed = document.Controls[0]; + document.Controls.RemoveAt(0); + DesignerRuntimeLayoutParityHarness.AssertParity("order-removed", document, document); + document.Controls.Add(removed); + DesignerRuntimeLayoutParityHarness.AssertParity("order-added", document, document); + } + + public static IEnumerable CoreParityScenarios() + { + yield return Scenario("ordinary-child", () => WithChildren(Node("child", "Panel", 17, 23, 91, 47))); + yield return Scenario("dock-fill", () => DockDocument([DockStyle.Fill])); + yield return Scenario("dock-top", () => DockDocument([DockStyle.Top])); + yield return Scenario("dock-bottom", () => DockDocument([DockStyle.Bottom])); + yield return Scenario("dock-left", () => DockDocument([DockStyle.Left])); + yield return Scenario("dock-right", () => DockDocument([DockStyle.Right])); + yield return Scenario("dock-top-fill", () => DockDocument([DockStyle.Top, DockStyle.Fill])); + yield return Scenario("dock-left-fill", () => DockDocument([DockStyle.Left, DockStyle.Fill])); + yield return Scenario("dock-top-bottom-fill", () => DockDocument([DockStyle.Top, DockStyle.Bottom, DockStyle.Fill])); + yield return Scenario("dock-left-right-fill", () => DockDocument([DockStyle.Left, DockStyle.Right, DockStyle.Fill])); + yield return Scenario("dock-repeated-top", () => DockDocument([DockStyle.Top, DockStyle.Top, DockStyle.Top, DockStyle.Fill])); + yield return Scenario("dock-mixed-sequence", () => DockDocument([DockStyle.Top, DockStyle.Left, DockStyle.Bottom, DockStyle.Right, DockStyle.Fill])); + yield return Scenario("padding-zero", () => DockDocument([DockStyle.Fill], Padding.Empty)); + yield return Scenario("padding-uniform", () => DockDocument([DockStyle.Fill], new Padding(12))); + yield return Scenario("padding-asymmetric-issue-31", () => DockDocument([DockStyle.Fill], new Padding(10, 20, 30, 40))); + yield return Scenario("padding-negative-normalized", () => DockDocument([DockStyle.Fill], new Padding(-5, 10, -15, -20))); + yield return Scenario("padding-margin-dock", PaddingAndMarginDocument); + yield return Scenario("nested-padding", NestedPaddingDocument); + yield return Scenario("usercontrol-root-padding", UserControlRootPaddingDocument); + yield return AnchorScenario("anchor-left-top", AnchorStyles.Left | AnchorStyles.Top, new DesignSize(420, 310)); + yield return AnchorScenario("anchor-right-top", AnchorStyles.Right | AnchorStyles.Top, new DesignSize(420, 310)); + yield return AnchorScenario("anchor-left-right-top", AnchorStyles.Left | AnchorStyles.Right | AnchorStyles.Top, new DesignSize(420, 310)); + yield return AnchorScenario("anchor-left-top-bottom", AnchorStyles.Left | AnchorStyles.Top | AnchorStyles.Bottom, new DesignSize(420, 310)); + yield return AnchorScenario("anchor-all", AnchorStyles.Left | AnchorStyles.Right | AnchorStyles.Top | AnchorStyles.Bottom, new DesignSize(420, 310)); + yield return AnchorScenario("anchor-none", AnchorStyles.None, new DesignSize(420, 310)); + yield return AnchorScenario("anchor-resize-wider", AnchorStyles.Right | AnchorStyles.Top, new DesignSize(420, 200)); + yield return AnchorScenario("anchor-resize-taller", AnchorStyles.Left | AnchorStyles.Bottom, new DesignSize(300, 310)); + yield return ConstraintScenario("minimum-size", minimum: new Size(120, 80), maximum: Size.Empty, DockStyle.None); + yield return ConstraintScenario("maximum-size", minimum: Size.Empty, maximum: new Size(60, 35), DockStyle.None); + yield return ConstraintScenario("minimum-maximum", minimum: new Size(70, 40), maximum: new Size(100, 60), DockStyle.None); + yield return ConstraintScenario("dock-fill-minimum", minimum: new Size(340, 240), maximum: Size.Empty, DockStyle.Fill); + yield return ConstraintScenario("dock-fill-maximum", minimum: Size.Empty, maximum: new Size(240, 150), DockStyle.Fill); + yield return Scenario("nested-panel-chain", NestedPanelDocument); + yield return Scenario("nested-usercontrol-chain", NestedUserControlDocument); + yield return Scenario("nested-form-panel-chain", NestedFormPanelDocument); + yield return Scenario("nested-form-usercontrol-chain", NestedFormUserControlDocument); + yield return Scenario("nested-usercontrol-resize", NestedUserControlDocument, new DesignSize(420, 310)); + yield return Scenario("usercontrol-child", UserControlChildDocument); + yield return Scenario("form-usercontrol-child", FormUserControlChildDocument); + yield return Scenario("form-root-fill", () => FormDocument([DockStyle.Fill])); + yield return Scenario("form-root-top-fill", () => FormDocument([DockStyle.Top, DockStyle.Fill])); + yield return Scenario("form-root-padded-container", FormPaddedContainerDocument); + yield return Scenario("form-root-anchor-resize", FormAnchorDocument, new DesignSize(420, 310)); + yield return Scenario("flow-left-to-right", () => FlowDocument()); + yield return Scenario("flow-top-down", () => FlowDocument(FlowDirection.TopDown)); + yield return Scenario("flow-wrap", FlowWrapDocument); + yield return Scenario("table-two-by-two", TableDocument); + yield return Scenario("hidden-dock-child", HiddenDockDocument); + yield return Scenario("hidden-dock-middle", HiddenDockMiddleDocument); + yield return Scenario("clipped-nested-child", ClippedChildDocument); + yield return Scenario("shape-logical-animation-bounds", ShapeAnimationDocument); + } + + public static IEnumerable PropertyGridLiveEdits() + { + yield return ["Dock", "Fill", null!]; + yield return ["Anchor", "Top, Right", new DesignSize(420, 310)]; + yield return ["Margin", "1, 2, 3, 4", null!]; + yield return ["Padding", "5, 10, 15, 20", null!]; + yield return ["MinimumSize", "100, 70", null!]; + yield return ["MaximumSize", "60, 35", null!]; + yield return ["Visible", "false", null!]; + yield return ["X", "35", null!]; + yield return ["Y", "45", null!]; + yield return ["Width", "125", null!]; + yield return ["Height", "95", null!]; + } + + public static IEnumerable RoundTripScenarios() + { + yield return Scenario("padding-dock", () => DockDocument([DockStyle.Top, DockStyle.Fill], new Padding(5, 10, 15, 20))); + yield return Scenario("nested-usercontrol", NestedUserControlDocument); + yield return Scenario("flow", () => FlowDocument()); + } + + public static IEnumerable CodeGenerationScenarios() + { + yield return Scenario("usercontrol-padding-dock", () => DockDocument([DockStyle.Top, DockStyle.Fill], new Padding(5, 10, 15, 20))); + yield return Scenario("usercontrol-nested-anchor", NestedUserControlDocument); + yield return Scenario("form-client-area", () => FormDocument([DockStyle.Top, DockStyle.Fill])); + } + + public static IEnumerable ReverseParserScenarios() + { + yield return Scenario("padding-dock", () => DockDocument([DockStyle.Top, DockStyle.Fill], new Padding(5, 10, 15, 20))); + yield return AnchorScenario("anchor", AnchorStyles.Right | AnchorStyles.Bottom, new DesignSize(420, 310)); + yield return Scenario("nested", NestedPanelDocument); + } + + private static object[] Scenario(string name, Func factory, DesignSize? size = null) + => [new ParityScenario(name, factory, size)]; + + private static object[] AnchorScenario(string name, AnchorStyles anchor, DesignSize size) + => Scenario( + name, + () => + { + var document = BasicDocument(); + var child = Node("anchored", "Panel", 40, 50, 100, 60); + child.Properties["Anchor"] = EnumValue(anchor); + document.Controls.Add(child); + return document; + }, + size); + + private static object[] ConstraintScenario(string name, Size minimum, Size maximum, DockStyle dock) + => Scenario( + name, + () => + { + var document = BasicDocument(); + var child = Node("constrained", "Panel", 20, 30, 80, 50, dock); + if (!minimum.IsEmpty) + child.Properties["MinimumSize"] = SizeValue(minimum); + if (!maximum.IsEmpty) + child.Properties["MaximumSize"] = SizeValue(maximum); + document.Controls.Add(child); + return document; + }); + + private static DesignDocument BasicDocument(DesignRootKind rootKind = DesignRootKind.UserControl) + => new() + { + Namespace = "ModernFormsNext.LayoutParity.Generated", + ClassName = rootKind == DesignRootKind.Form ? "ParityForm" : "ParityControl", + FormName = rootKind == DesignRootKind.Form ? "ParityForm" : "ParityControl", + RootKind = rootKind, + Size = new DesignSize(300, 200) + }; + + private static DesignDocument WithChildren(params DesignControlNode[] children) + { + var document = BasicDocument(); + foreach (var child in children) + document.Controls.Add(child); + return document; + } + + private static DesignDocument DockDocument(IReadOnlyList docks, Padding? padding = null) + { + var document = BasicDocument(); + if (padding is { } rootPadding) + document.Properties["Padding"] = PaddingValue(rootPadding); + + for (var index = 0; index < docks.Count; index++) + { + var thickness = 24 + (index * 3); + document.Controls.Add(Node($"child{index + 1}", "Panel", 11 + index, 17 + index, thickness, thickness, docks[index])); + } + return document; + } + + private static DesignDocument FormDocument(IReadOnlyList docks) + { + var document = BasicDocument(DesignRootKind.Form); + for (var index = 0; index < docks.Count; index++) + document.Controls.Add(Node($"formChild{index + 1}", "Panel", 0, 0, 30 + index, 30 + index, docks[index])); + return document; + } + + private static DesignDocument PaddingAndMarginDocument() + { + var document = DockDocument([DockStyle.Fill], new Padding(5, 10, 15, 20)); + document.Controls[0].Properties["Margin"] = PaddingValue(new Padding(1, 2, 3, 4)); + return document; + } + + private static DesignDocument NestedPaddingDocument() + { + var document = BasicDocument(); + document.Properties["Padding"] = PaddingValue(new Padding(4)); + var outer = Node("outer", "Panel", 0, 0, 100, 100, DockStyle.Fill); + outer.Properties["Padding"] = PaddingValue(new Padding(5, 10, 15, 20)); + var inner = Node("inner", "Panel", 0, 0, 80, 80, DockStyle.Fill); + inner.Properties["Padding"] = PaddingValue(new Padding(3, 6, 9, 12)); + inner.Children.Add(Node("leaf", "Panel", 0, 0, 20, 20, DockStyle.Fill)); + outer.Children.Add(inner); + document.Controls.Add(outer); + return document; + } + + private static DesignDocument UserControlRootPaddingDocument() + { + var document = BasicDocument(); + document.Properties["Padding"] = PaddingValue(new Padding(10, 20, 30, 40)); + document.Controls.Add(Node("fill", "Panel", 0, 0, 20, 20, DockStyle.Fill)); + return document; + } + + private static DesignDocument NestedPanelDocument() + => NestedPanelDocument(DesignRootKind.UserControl); + + private static DesignDocument NestedFormPanelDocument() + => NestedPanelDocument(DesignRootKind.Form); + + private static DesignDocument NestedPanelDocument(DesignRootKind rootKind) + { + var document = BasicDocument(rootKind); + var outer = Node("outer", "Panel", 10, 10, 280, 180); + var inner = Node("inner", "Panel", 15, 20, 220, 120); + inner.Children.Add(Node("leaf", "Button", 25, 30, 90, 32)); + outer.Children.Add(inner); + document.Controls.Add(outer); + return document; + } + + private static DesignDocument NestedUserControlDocument() + => NestedUserControlDocument(DesignRootKind.UserControl); + + private static DesignDocument NestedFormUserControlDocument() + => NestedUserControlDocument(DesignRootKind.Form); + + private static DesignDocument NestedUserControlDocument(DesignRootKind rootKind) + { + var document = BasicDocument(rootKind); + var userControl = Node("card", "UserControl", 10, 10, 280, 180); + userControl.Properties["Padding"] = PaddingValue(new Padding(8)); + var panel = Node("content", "Panel", 0, 0, 100, 100, DockStyle.Fill); + panel.Children.Add(Node("anchored", "Button", 150, 100, 90, 32)); + panel.Children[0].Properties["Anchor"] = EnumValue(AnchorStyles.Right | AnchorStyles.Bottom); + userControl.Children.Add(panel); + document.Controls.Add(userControl); + return document; + } + + private static DesignDocument UserControlChildDocument() + => UserControlChildDocument(DesignRootKind.UserControl); + + private static DesignDocument FormUserControlChildDocument() + => UserControlChildDocument(DesignRootKind.Form); + + private static DesignDocument UserControlChildDocument(DesignRootKind rootKind) + { + var document = BasicDocument(rootKind); + var child = Node("childUserControl", "UserControl", 20, 25, 200, 120); + child.Properties["Padding"] = PaddingValue(new Padding(7)); + child.Children.Add(Node("fill", "Panel", 0, 0, 20, 20, DockStyle.Fill)); + document.Controls.Add(child); + return document; + } + + private static DesignDocument FormPaddedContainerDocument() + { + var document = BasicDocument(DesignRootKind.Form); + var panel = Node("content", "Panel", 0, 0, 20, 20, DockStyle.Fill); + panel.Properties["Padding"] = PaddingValue(new Padding(10, 20, 30, 40)); + panel.Children.Add(Node("fill", "Panel", 0, 0, 20, 20, DockStyle.Fill)); + document.Controls.Add(panel); + return document; + } + + private static DesignDocument FormAnchorDocument() + { + var document = BasicDocument(DesignRootKind.Form); + var child = Node("anchored", "Panel", 200, 130, 80, 50); + child.Properties["Anchor"] = EnumValue(AnchorStyles.Right | AnchorStyles.Bottom); + document.Controls.Add(child); + return document; + } + + private static DesignDocument FlowDocument(FlowDirection direction = FlowDirection.LeftToRight) + { + var document = BasicDocument(); + var flow = Node("flow", "FlowLayoutPanel", 0, 0, 20, 20, DockStyle.Fill); + flow.Properties["FlowDirection"] = EnumValue(direction); + flow.Children.Add(Node("first", "Panel", 0, 0, 40, 20)); + flow.Children.Add(Node("second", "Panel", 0, 0, 50, 30)); + flow.Children.Add(Node("third", "Panel", 0, 0, 30, 25)); + document.Controls.Add(flow); + return document; + } + + private static DesignDocument FlowWrapDocument() + { + var document = BasicDocument(); + document.Size = new DesignSize(120, 100); + var flow = Node("flow", "FlowLayoutPanel", 0, 0, 20, 20, DockStyle.Fill); + flow.Children.Add(Node("first", "Panel", 0, 0, 70, 20)); + flow.Children.Add(Node("second", "Panel", 0, 0, 70, 25)); + document.Controls.Add(flow); + return document; + } + + private static DesignDocument TableDocument() + { + var document = BasicDocument(); + var table = Node("table", "TableLayoutPanel", 0, 0, 20, 20, DockStyle.Fill); + table.Properties["ColumnCount"] = DesignPropertyValue.FromInt32(2); + table.Properties["RowCount"] = DesignPropertyValue.FromInt32(2); + table.Children.Add(TableChild("topLeft", 0, 0)); + table.Children.Add(TableChild("topRight", 1, 0)); + table.Children.Add(TableChild("bottomLeft", 0, 1)); + table.Children.Add(TableChild("bottomRight", 1, 1)); + document.Controls.Add(table); + return document; + } + + private static DesignControlNode TableChild(string name, int column, int row) + { + var child = Node(name, "Panel", 0, 0, 40, 30); + child.Properties["TableColumn"] = DesignPropertyValue.FromInt32(column); + child.Properties["TableRow"] = DesignPropertyValue.FromInt32(row); + return child; + } + + private static DesignDocument HiddenDockDocument() + { + var document = DockDocument([DockStyle.Top, DockStyle.Fill]); + document.Controls[0].Properties["Visible"] = DesignPropertyValue.FromBoolean(false); + return document; + } + + private static DesignDocument HiddenDockMiddleDocument() + { + var document = DockDocument([DockStyle.Top, DockStyle.Top, DockStyle.Fill]); + document.Controls[1].Properties["Visible"] = DesignPropertyValue.FromBoolean(false); + return document; + } + + private static DesignDocument ClippedChildDocument() + { + var document = BasicDocument(); + var parent = Node("clipParent", "Panel", 20, 25, 100, 80); + parent.Children.Add(Node("overflow", "Panel", 70, 55, 60, 50)); + document.Controls.Add(parent); + return document; + } + + private static DesignDocument ShapeAnimationDocument() + { + var document = BasicDocument(); + var shape = Node("shape", "Ellipse", 0, 0, 40, 40, DockStyle.Fill); + shape.Properties[LayoutTransitionDesignValue.PropertyName] = + LayoutTransitionDesignValue.Create(enabled: true, durationMilliseconds: 0d, easing: "EaseOut"); + document.Controls.Add(shape); + return document; + } + + private static DesignControlNode Node( + string name, + string typeName, + int x, + int y, + int width, + int height, + DockStyle dock = DockStyle.None) + { + var node = new DesignControlNode + { + Name = name, + TypeName = typeName, + Bounds = new DesignBounds(x, y, width, height) + }; + node.Properties["Dock"] = EnumValue(dock); + return node; + } + + private static DesignPropertyValue EnumValue(T value) where T : struct, Enum + => DesignPropertyValue.FromEnum(typeof(T).FullName!, value.ToString()); + + private static DesignPropertyValue PaddingValue(Padding value) + => DesignerPropertyValueEditor.ToDesignPropertyValue(value, typeof(Padding)); + + private static DesignPropertyValue SizeValue(Size value) + => DesignerPropertyValueEditor.ToDesignPropertyValue(value, typeof(Size)); + + private static void Commit(DesignerPropertyGridState propertyGrid, string propertyName, string value) + { + var property = Assert.Single(propertyGrid.Properties, candidate => candidate.Name == propertyName); + propertyGrid.SelectRow(new DesignerPropertyGridRow(property)); + Assert.True(propertyGrid.CommitSelectedValue(value)); + } + + private static GeneratedRoot CompileAndCreateGeneratedRoot(DesignDocument document) + { + var generated = new CSharpDesignerGenerator().Generate(document); + Assert.True(generated.Succeeded, string.Join(Environment.NewLine, generated.Validation.Errors)); + var baseType = document.RootKind == DesignRootKind.Form + ? "ModernFormsNext.Form" + : "ModernFormsNext.UserControl"; + var userCode = $$""" + namespace {{document.Namespace}}; + public partial class {{document.ClassName}} : {{baseType}} + { + public {{document.ClassName}}() => InitializeComponent(); + } + """; + var trustedAssemblies = ((string?)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")) + ?.Split(System.IO.Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries) ?? []; + var references = trustedAssemblies + .Concat(AppDomain.CurrentDomain.GetAssemblies() + .Where(assembly => !assembly.IsDynamic && !string.IsNullOrWhiteSpace(assembly.Location)) + .Select(assembly => assembly.Location)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Select(path => MetadataReference.CreateFromFile(path)); + var artifactId = Interlocked.Increment(ref nextArtifactId); + var assemblyName = $"ModernFormsNext.LayoutParity.Generated.P{Environment.ProcessId}.A{artifactId}"; + var compilation = CSharpCompilation.Create( + assemblyName, + [CSharpSyntaxTree.ParseText(generated.Code), CSharpSyntaxTree.ParseText(userCode)], + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + using var stream = new MemoryStream(); + var emit = compilation.Emit(stream); + Assert.True( + emit.Success, + string.Join(Environment.NewLine, emit.Diagnostics.Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error))); + stream.Position = 0; + var assembly = AssemblyLoadContext.Default.LoadFromStream(stream); + var rootType = assembly.GetType($"{document.Namespace}.{document.ClassName}"); + Assert.NotNull(rootType); + var instance = Activator.CreateInstance(rootType); + Assert.NotNull(instance); + return new GeneratedRoot(instance); + } + + private sealed class GeneratedRoot(object instance) : IDisposable + { + public object Instance { get; } = instance; + + public void Dispose() + { + if (Instance is IDisposable disposable) + disposable.Dispose(); + } + } +} diff --git a/ModernFormsNext.Designer/Properties/DesignerPropertyGridState.cs b/ModernFormsNext.Designer/Properties/DesignerPropertyGridState.cs index 61fd136..9a37cf9 100644 --- a/ModernFormsNext.Designer/Properties/DesignerPropertyGridState.cs +++ b/ModernFormsNext.Designer/Properties/DesignerPropertyGridState.cs @@ -1,4 +1,5 @@ using ModernFormsNext.Designer.Services; +using ModernFormsNext.Designer.Surface; using ModernFormsNext.Designing; using System.ComponentModel; using System.Reflection; @@ -1221,9 +1222,10 @@ private DesignerPropertyDescriptor FormSize( return (false, "The design root size must be greater than zero."); var size = playgroundState.Document.Size; - playgroundState.Document.Size = name == "Width" + var nextSize = name == "Width" ? createSize(sizeValue, size.Height) : createSize(size.Width, sizeValue); + new DesignerLayoutEngine().ResizeRoot(playgroundState.Document, nextSize); return (true, null); } }; diff --git a/ModernFormsNext.Designer/Services/DesignerSession.cs b/ModernFormsNext.Designer/Services/DesignerSession.cs index 0e9152c..ce6dea7 100644 --- a/ModernFormsNext.Designer/Services/DesignerSession.cs +++ b/ModernFormsNext.Designer/Services/DesignerSession.cs @@ -907,7 +907,9 @@ public void UpdateSelectedProperties( if (SelectedNode is null) { Document.FormName = string.IsNullOrWhiteSpace(name) ? Document.FormName : name.Trim(); - Document.Size = new DesignSize(Math.Max(1, width), Math.Max(1, height)); + new DesignerLayoutEngine().ResizeRoot( + Document, + new DesignSize(Math.Max(1, width), Math.Max(1, height))); NotifyDocumentChanged(); Log($"Updated form {Document.FormName}."); return; diff --git a/ModernFormsNext.Designer/Surface/DesignerHitTestService.cs b/ModernFormsNext.Designer/Surface/DesignerHitTestService.cs index e0951d5..5dd76a5 100644 --- a/ModernFormsNext.Designer/Surface/DesignerHitTestService.cs +++ b/ModernFormsNext.Designer/Surface/DesignerHitTestService.cs @@ -50,6 +50,9 @@ public DesignerResizeHandle HitTestResizeHandle( { var selectedNode = state.SelectedNode; + if (selectedNode is not null && !DesignerLayoutProperties.IsVisible(selectedNode)) + return DesignerResizeHandle.None; + if (selectedNode is null) { if (state.Document.RootKind != DesignRootKind.UserControl) @@ -165,6 +168,9 @@ private static bool Contains(Rectangle bounds, float x, float y) foreach (var index in GetFrontToBackIndices(orderedControls.Count, parentNode)) { var control = orderedControls[index]; + if (!DesignerLayoutProperties.IsVisible(control)) + continue; + var absoluteBounds = layout.GetEffectiveBounds(control); var visibleBounds = Intersect(absoluteBounds, parentClip); @@ -212,6 +218,9 @@ private static IEnumerable GetHitTestChildren(DesignControlNo foreach (var index in GetFrontToBackIndices(orderedControls.Count, parentNode)) { var control = orderedControls[index]; + if (!DesignerLayoutProperties.IsVisible(control)) + continue; + var absoluteBounds = layout.GetEffectiveBounds(control); var visibleBounds = Intersect(absoluteBounds, parentClip); @@ -249,6 +258,9 @@ private static IEnumerable GetHitTestChildren(DesignControlNo foreach (var index in GetFrontToBackIndices(orderedControls.Count, parentNode)) { var control = orderedControls[index]; + if (!DesignerLayoutProperties.IsVisible(control)) + continue; + var absoluteBounds = layout.GetEffectiveBounds(control); var visibleBounds = Intersect(absoluteBounds, parentClip); diff --git a/ModernFormsNext.Designer/Surface/DesignerMouseController.cs b/ModernFormsNext.Designer/Surface/DesignerMouseController.cs index 924cabd..0799037 100644 --- a/ModernFormsNext.Designer/Surface/DesignerMouseController.cs +++ b/ModernFormsNext.Designer/Surface/DesignerMouseController.cs @@ -304,7 +304,7 @@ private void UpdateRootResize(int deltaX, int deltaY) if (state.Document.Size == nextSize) return; - state.Document.Size = nextSize; + layoutEngine.ResizeRoot(state.Document, nextSize); changedBounds = true; state.NotifyDocumentChanged(); } diff --git a/ModernFormsNext.Designer/Surface/DesignerSurfaceRenderer.cs b/ModernFormsNext.Designer/Surface/DesignerSurfaceRenderer.cs index eb8a81a..e3e8aa4 100644 --- a/ModernFormsNext.Designer/Surface/DesignerSurfaceRenderer.cs +++ b/ModernFormsNext.Designer/Surface/DesignerSurfaceRenderer.cs @@ -146,7 +146,7 @@ private void DrawNode( int offsetY, HashSet previewStack) { - if (!IsDesignNodeVisible(node)) + if (!DesignerLayoutProperties.IsVisible(node)) return; var localAbsolute = layout.GetEffectiveBounds(node); @@ -763,7 +763,7 @@ private void LogRuntimeRenderDiagnostics( control.Text, background, foreground, - IsDesignNodeVisible(node), + DesignerLayoutProperties.IsVisible(node), control.Enabled); if (runtimeRenderDiagnostics.TryGetValue(node, out var previous) && previous == signature) @@ -777,19 +777,6 @@ private void LogRuntimeRenderDiagnostics( $"Text='{control.Text}', BackColor={background}, ForeColor={foreground}."); } - private static bool IsDesignNodeVisible(DesignControlNode node) - { - if (!node.Properties.TryGetValue("Visible", out var value)) - return true; - - return value.Kind switch - { - DesignPropertyValueKind.Boolean when value.Value is bool visible => visible, - DesignPropertyValueKind.String when bool.TryParse(value.ToString(), out var visible) => visible, - _ => true - }; - } - private static string NormalizeRuntimePropertyPath(string path) => path.StartsWith("CurrentStyle.", StringComparison.Ordinal) ? "Style" + path["CurrentStyle".Length..] diff --git a/ModernFormsNext.Designer/Surface/Layout/DesignerLayoutEngine.cs b/ModernFormsNext.Designer/Surface/Layout/DesignerLayoutEngine.cs index b0038ec..bac7ed1 100644 --- a/ModernFormsNext.Designer/Surface/Layout/DesignerLayoutEngine.cs +++ b/ModernFormsNext.Designer/Surface/Layout/DesignerLayoutEngine.cs @@ -10,24 +10,58 @@ public DesignerLayoutResult Layout(DesignDocument document) => Layout(document, document.Size); public DesignerLayoutResult Layout(DesignDocument document, DesignSize rootSize) + { + var normalizedRootSize = new DesignSize(Math.Max(1, rootSize.Width), Math.Max(1, rootSize.Height)); + var baseline = normalizedRootSize == document.Size + ? null + : LayoutCore(document, document.Size, baseline: null); + return LayoutCore(document, normalizedRootSize, baseline); + } + + private static DesignerLayoutResult LayoutCore( + DesignDocument document, + DesignSize rootSize, + DesignerLayoutResult? baseline) { var bounds = new Dictionary(); - var documentBounds = new DesignBounds(0, 0, Math.Max(1, rootSize.Width), Math.Max(1, rootSize.Height)); + var documentBounds = new DesignBounds(0, 0, rootSize.Width, rootSize.Height); var documentContentBounds = document.RootKind == DesignRootKind.UserControl ? DesignerLayoutProperties.GetPaddedContentBounds(documentBounds, DesignerLayoutProperties.GetPadding(document.Properties)) : documentBounds; - LayoutGenericChildren(document.Controls, documentBounds, documentContentBounds, document.Size, bounds); + LayoutGenericChildren(document.Controls, documentBounds, documentContentBounds, document.Size, bounds, baseline); return new DesignerLayoutResult(bounds); } + /// + /// Applies a design-root resize while preserving the runtime Anchor result in persisted bounds. + /// + /// + /// The document stores the final authored geometry that generated code will assign at startup. + /// Calculate against the old root size first, then persist only non-docked bounds; docked + /// controls retain their authored thickness and are recalculated by the runtime layout engine. + /// + public void ResizeRoot(DesignDocument document, DesignSize rootSize) + { + ArgumentNullException.ThrowIfNull(document); + + var normalizedSize = new DesignSize(Math.Max(1, rootSize.Width), Math.Max(1, rootSize.Height)); + if (document.Size == normalizedSize) + return; + + var layout = Layout(document, normalizedSize); + PersistAnchoredBounds(document.Controls, parentBounds: default, parentNode: null, layout); + document.Size = normalizedSize; + } + private static void LayoutGenericChildren( DesignControlCollection children, DesignBounds parentBounds, DesignBounds parentContentBounds, DesignSize parentDesignSize, - IDictionary bounds) + IDictionary bounds, + DesignerLayoutResult? baseline) { var remaining = new DesignBounds( parentContentBounds.X - parentBounds.X, @@ -40,7 +74,10 @@ private static void LayoutGenericChildren( // taken from Height (Top/Bottom) or Width (Left/Right). foreach (var child in children) { - var localBounds = GetLocalBounds(child, remaining, parentBounds, parentDesignSize); + var participatesInLayout = DesignerLayoutProperties.IsVisible(child); + var localBounds = participatesInLayout + ? GetLocalBounds(child, remaining, parentBounds, parentDesignSize) + : DesignerLayoutProperties.ApplySizeConstraints(child, child.Bounds); var absoluteBounds = new DesignBounds( parentBounds.X + localBounds.X, parentBounds.Y + localBounds.Y, @@ -48,52 +85,61 @@ private static void LayoutGenericChildren( Math.Max(0, localBounds.Height)); bounds[child] = absoluteBounds; - remaining = ConsumeDockSpace(child, remaining, localBounds); + if (participatesInLayout) + remaining = ConsumeDockSpace(child, remaining, localBounds); - LayoutContainerChildren(child, absoluteBounds, bounds); + LayoutContainerChildren(child, absoluteBounds, bounds, baseline); } } private static void LayoutContainerChildren( DesignControlNode container, DesignBounds containerBounds, - IDictionary bounds) + IDictionary bounds, + DesignerLayoutResult? baseline) { DesignerSpecialContainers.EnsureSpecialChildren(container); var contentBounds = DesignerLayoutProperties.GetContainerContentBounds(container, containerBounds); if (DesignerSpecialContainers.IsSplitContainer(container)) { - LayoutSplitContainer(container, contentBounds, bounds); + LayoutSplitContainer(container, contentBounds, bounds, baseline); return; } if (DesignerSpecialContainers.IsTabControl(container)) { - LayoutTabControl(container, contentBounds, bounds); + LayoutTabControl(container, contentBounds, bounds, baseline); return; } if (DesignerSpecialContainers.IsFlowLayoutPanel(container)) { - LayoutFlowLayoutPanel(container, contentBounds, bounds); + LayoutFlowLayoutPanel(container, contentBounds, bounds, baseline); return; } if (DesignerSpecialContainers.IsTableLayoutPanel(container)) { - LayoutTableLayoutPanel(container, contentBounds, bounds); + LayoutTableLayoutPanel(container, contentBounds, bounds, baseline); return; } if (container.Children.Count > 0) - LayoutGenericChildren(container.Children, containerBounds, contentBounds, GetDesignSize(container), bounds); + LayoutGenericChildren( + container.Children, + containerBounds, + contentBounds, + GetBaselineContainerSize(container, containerBounds, baseline), + bounds, + baseline); } private static void LayoutSplitContainer( DesignControlNode splitContainer, DesignBounds bounds, - IDictionary effectiveBounds) + IDictionary effectiveBounds, + DesignerLayoutResult? baseline) { var panel1 = splitContainer.Children.FirstOrDefault(DesignerSpecialContainers.IsSplitPanel1); var panel2 = splitContainer.Children.FirstOrDefault(DesignerSpecialContainers.IsSplitPanel2); @@ -127,14 +173,16 @@ private static void LayoutSplitContainer( panel1.Children, panel1Bounds, DesignerLayoutProperties.GetContainerContentBounds(panel1, panel1Bounds), - panel1DesignSize, - effectiveBounds); + GetBaselineContainerSize(panel1, panel1Bounds, baseline, panel1DesignSize), + effectiveBounds, + baseline); LayoutGenericChildren( panel2.Children, panel2Bounds, DesignerLayoutProperties.GetContainerContentBounds(panel2, panel2Bounds), - panel2DesignSize, - effectiveBounds); + GetBaselineContainerSize(panel2, panel2Bounds, baseline, panel2DesignSize), + effectiveBounds, + baseline); } else { @@ -155,21 +203,24 @@ private static void LayoutSplitContainer( panel1.Children, panel1Bounds, DesignerLayoutProperties.GetContainerContentBounds(panel1, panel1Bounds), - panel1DesignSize, - effectiveBounds); + GetBaselineContainerSize(panel1, panel1Bounds, baseline, panel1DesignSize), + effectiveBounds, + baseline); LayoutGenericChildren( panel2.Children, panel2Bounds, DesignerLayoutProperties.GetContainerContentBounds(panel2, panel2Bounds), - panel2DesignSize, - effectiveBounds); + GetBaselineContainerSize(panel2, panel2Bounds, baseline, panel2DesignSize), + effectiveBounds, + baseline); } } private static void LayoutTabControl( DesignControlNode tabControl, DesignBounds bounds, - IDictionary effectiveBounds) + IDictionary effectiveBounds, + DesignerLayoutResult? baseline) { var headerHeight = Math.Min(28, Math.Max(20, bounds.Height / 4)); var pageBounds = new DesignBounds( @@ -192,58 +243,69 @@ private static void LayoutTabControl( selectedPage.Children, pageBounds, DesignerLayoutProperties.GetContainerContentBounds(selectedPage, pageBounds), - pageDesignSizes[selectedPage], - effectiveBounds); + GetBaselineContainerSize(selectedPage, pageBounds, baseline, pageDesignSizes[selectedPage]), + effectiveBounds, + baseline); } private static void LayoutFlowLayoutPanel( DesignControlNode panel, DesignBounds bounds, - IDictionary effectiveBounds) + IDictionary effectiveBounds, + DesignerLayoutResult? baseline) { var direction = DesignerSpecialContainers.GetEnum(panel, DesignerSpecialContainers.FlowDirectionPropertyName, FlowDirection.LeftToRight); var wrap = DesignerSpecialContainers.GetBoolean(panel, DesignerSpecialContainers.WrapContentsPropertyName, true); - var gap = 6; var cursorX = 0; var cursorY = 0; var lineSize = 0; foreach (var child in panel.Children) { - var width = Math.Max(1, child.Bounds.Width); - var height = Math.Max(1, child.Bounds.Height); + if (!DesignerLayoutProperties.IsVisible(child)) + { + SetEffectiveChildBounds(child, bounds, child.Bounds.X, child.Bounds.Y, child.Bounds.Width, child.Bounds.Height, effectiveBounds, baseline); + continue; + } + + var constrained = DesignerLayoutProperties.ApplySizeConstraints(child, child.Bounds); + var margin = DesignerLayoutProperties.GetMargin(child); + var width = Math.Max(1, constrained.Width); + var height = Math.Max(1, constrained.Height); + var requiredWidth = width + margin.Horizontal; + var requiredHeight = height + margin.Vertical; if (direction is FlowDirection.LeftToRight or FlowDirection.RightToLeft) { - if (wrap && cursorX > 0 && cursorX + width > bounds.Width) + if (wrap && cursorX > 0 && cursorX + requiredWidth > bounds.Width) { cursorX = 0; - cursorY += lineSize + gap; + cursorY += lineSize; lineSize = 0; } var localX = direction == FlowDirection.LeftToRight - ? cursorX - : Math.Max(0, bounds.Width - cursorX - width); - SetEffectiveChildBounds(child, bounds, localX, cursorY, width, height, effectiveBounds); - cursorX += width + gap; - lineSize = Math.Max(lineSize, height); + ? cursorX + margin.Left + : Math.Max(0, bounds.Width - cursorX - margin.Right - width); + SetEffectiveChildBounds(child, bounds, localX, cursorY + margin.Top, width, height, effectiveBounds, baseline); + cursorX += requiredWidth; + lineSize = Math.Max(lineSize, requiredHeight); } else { - if (wrap && cursorY > 0 && cursorY + height > bounds.Height) + if (wrap && cursorY > 0 && cursorY + requiredHeight > bounds.Height) { cursorY = 0; - cursorX += lineSize + gap; + cursorX += lineSize; lineSize = 0; } var localY = direction == FlowDirection.TopDown - ? cursorY - : Math.Max(0, bounds.Height - cursorY - height); - SetEffectiveChildBounds(child, bounds, cursorX, localY, width, height, effectiveBounds); - cursorY += height + gap; - lineSize = Math.Max(lineSize, width); + ? cursorY + margin.Top + : Math.Max(0, bounds.Height - cursorY - margin.Bottom - height); + SetEffectiveChildBounds(child, bounds, cursorX + margin.Left, localY, width, height, effectiveBounds, baseline); + cursorY += requiredHeight; + lineSize = Math.Max(lineSize, requiredWidth); } } } @@ -251,16 +313,56 @@ private static void LayoutFlowLayoutPanel( private static void LayoutTableLayoutPanel( DesignControlNode panel, DesignBounds bounds, - IDictionary effectiveBounds) + IDictionary effectiveBounds, + DesignerLayoutResult? baseline) { var columns = Math.Max(1, DesignerSpecialContainers.GetInt(panel, DesignerSpecialContainers.ColumnCountPropertyName, 2)); var rows = Math.Max(1, DesignerSpecialContainers.GetInt(panel, DesignerSpecialContainers.RowCountPropertyName, 2)); - var cellWidth = Math.Max(1, bounds.Width / columns); - var cellHeight = Math.Max(1, bounds.Height / rows); + var columnWidths = new int[columns]; + var rowHeights = new int[rows]; var childIndex = 0; + // The runtime's default TableLayoutStyle is AutoSize. Size each strip from the + // authored child plus Margin; unallocated space remains after the final auto strip. + foreach (var child in panel.Children.Where(DesignerLayoutProperties.IsVisible)) + { + var column = Math.Clamp( + DesignerSpecialContainers.GetInt(child, DesignerSpecialContainers.TableColumnPropertyName, childIndex % columns), + 0, + columns - 1); + var row = Math.Clamp( + DesignerSpecialContainers.GetInt(child, DesignerSpecialContainers.TableRowPropertyName, childIndex / columns), + 0, + rows - 1); + var columnSpan = Math.Min( + Math.Max(1, DesignerSpecialContainers.GetInt(child, DesignerSpecialContainers.TableColumnSpanPropertyName, 1)), + columns - column); + var rowSpan = Math.Min( + Math.Max(1, DesignerSpecialContainers.GetInt(child, DesignerSpecialContainers.TableRowSpanPropertyName, 1)), + rows - row); + var constrained = DesignerLayoutProperties.ApplySizeConstraints(child, child.Bounds); + var margin = DesignerLayoutProperties.GetMargin(child); + var perColumn = Math.Max(1, (int)Math.Ceiling((constrained.Width + margin.Horizontal) / (double)columnSpan)); + var perRow = Math.Max(1, (int)Math.Ceiling((constrained.Height + margin.Vertical) / (double)rowSpan)); + + for (var index = column; index < column + columnSpan; index++) + columnWidths[index] = Math.Max(columnWidths[index], perColumn); + for (var index = row; index < row + rowSpan; index++) + rowHeights[index] = Math.Max(rowHeights[index], perRow); + childIndex++; + } + + childIndex = 0; + foreach (var child in panel.Children) { + if (!DesignerLayoutProperties.IsVisible(child)) + { + SetEffectiveChildBounds(child, bounds, child.Bounds.X, child.Bounds.Y, child.Bounds.Width, child.Bounds.Height, effectiveBounds, baseline); + childIndex++; + continue; + } + var column = DesignerSpecialContainers.GetInt(child, DesignerSpecialContainers.TableColumnPropertyName, childIndex % columns); var row = DesignerSpecialContainers.GetInt(child, DesignerSpecialContainers.TableRowPropertyName, childIndex / columns); var columnSpan = Math.Max(1, DesignerSpecialContainers.GetInt(child, DesignerSpecialContainers.TableColumnSpanPropertyName, 1)); @@ -271,16 +373,49 @@ private static void LayoutTableLayoutPanel( columnSpan = Math.Min(columnSpan, columns - column); rowSpan = Math.Min(rowSpan, rows - row); - var localX = column * cellWidth; - var localY = row * cellHeight; - var width = columnSpan == columns - column ? bounds.Width - localX : cellWidth * columnSpan; - var height = rowSpan == rows - row ? bounds.Height - localY : cellHeight * rowSpan; - - SetEffectiveChildBounds(child, bounds, localX + 3, localY + 3, Math.Max(1, width - 6), Math.Max(1, height - 6), effectiveBounds); + var cellX = columnWidths.Take(column).Sum(); + var cellY = rowHeights.Take(row).Sum(); + var cellWidth = columnWidths.Skip(column).Take(columnSpan).Sum(); + var cellHeight = rowHeights.Skip(row).Take(rowSpan).Sum(); + var margin = DesignerLayoutProperties.GetMargin(child); + var available = new DesignBounds( + cellX + margin.Left, + cellY + margin.Top, + Math.Max(0, cellWidth - margin.Horizontal), + Math.Max(0, cellHeight - margin.Vertical)); + var constrained = DesignerLayoutProperties.ApplySizeConstraints(child, child.Bounds); + var anchor = GetUnifiedTableAnchor(child); + var stretchesHorizontally = (anchor & (AnchorStyles.Left | AnchorStyles.Right)) == (AnchorStyles.Left | AnchorStyles.Right); + var stretchesVertically = (anchor & (AnchorStyles.Top | AnchorStyles.Bottom)) == (AnchorStyles.Top | AnchorStyles.Bottom); + var width = stretchesHorizontally ? available.Width : Math.Min(constrained.Width, available.Width); + var height = stretchesVertically ? available.Height : Math.Min(constrained.Height, available.Height); + var localX = (anchor & AnchorStyles.Left) != 0 + ? available.X + : (anchor & AnchorStyles.Right) != 0 + ? available.Right - width + : available.X + ((available.Width - width) / 2); + var localY = (anchor & AnchorStyles.Top) != 0 + ? available.Y + : (anchor & AnchorStyles.Bottom) != 0 + ? available.Bottom - height + : available.Y + ((available.Height - height) / 2); + + SetEffectiveChildBounds(child, bounds, localX, localY, width, height, effectiveBounds, baseline); childIndex++; } } + private static AnchorStyles GetUnifiedTableAnchor(DesignControlNode child) + => DesignerLayoutProperties.GetDock(child) switch + { + DockStyle.Top => AnchorStyles.Left | AnchorStyles.Top | AnchorStyles.Right, + DockStyle.Bottom => AnchorStyles.Left | AnchorStyles.Bottom | AnchorStyles.Right, + DockStyle.Left => AnchorStyles.Left | AnchorStyles.Top | AnchorStyles.Bottom, + DockStyle.Right => AnchorStyles.Right | AnchorStyles.Top | AnchorStyles.Bottom, + DockStyle.Fill => AnchorStyles.Left | AnchorStyles.Top | AnchorStyles.Right | AnchorStyles.Bottom, + _ => DesignerLayoutProperties.GetAnchor(child) + }; + private static void SetEffectiveChildBounds( DesignControlNode child, DesignBounds parentBounds, @@ -288,11 +423,12 @@ private static void SetEffectiveChildBounds( int localY, int width, int height, - IDictionary effectiveBounds) + IDictionary effectiveBounds, + DesignerLayoutResult? baseline) { var absolute = new DesignBounds(parentBounds.X + localX, parentBounds.Y + localY, width, height); effectiveBounds[child] = absolute; - LayoutContainerChildren(child, absolute, effectiveBounds); + LayoutContainerChildren(child, absolute, effectiveBounds, baseline); } private static DesignBounds GetLocalBounds( @@ -307,7 +443,7 @@ private static DesignBounds GetLocalBounds( if (dock != DockStyle.None) { - return dock switch + var docked = dock switch { DockStyle.Top => new DesignBounds(remaining.X, remaining.Y, remaining.Width, Math.Min(height, remaining.Height)), DockStyle.Bottom => new DesignBounds(remaining.X, remaining.Bottom - Math.Min(height, remaining.Height), remaining.Width, Math.Min(height, remaining.Height)), @@ -316,6 +452,8 @@ private static DesignBounds GetLocalBounds( DockStyle.Fill => remaining, _ => node.Bounds }; + + return DesignerLayoutProperties.ApplySizeConstraints(node, docked); } var anchor = DesignerLayoutProperties.GetAnchor(node); @@ -345,12 +483,58 @@ private static DesignBounds GetLocalBounds( else if (!anchoredTop) y += heightDelta / 2; - return new DesignBounds(x, y, width, height); + return DesignerLayoutProperties.ApplySizeConstraints(node, new DesignBounds(x, y, width, height)); } private static DesignSize GetDesignSize(DesignControlNode node) => new(Math.Max(1, node.Bounds.Width), Math.Max(1, node.Bounds.Height)); + private static DesignSize GetBaselineContainerSize( + DesignControlNode container, + DesignBounds currentBounds, + DesignerLayoutResult? baseline, + DesignSize? fallback = null) + { + if (baseline is null) + { + return fallback ?? new DesignSize( + Math.Max(1, currentBounds.Width), + Math.Max(1, currentBounds.Height)); + } + + var baselineBounds = baseline.GetEffectiveBounds(container); + return new DesignSize(Math.Max(1, baselineBounds.Width), Math.Max(1, baselineBounds.Height)); + } + + private static void PersistAnchoredBounds( + DesignControlCollection children, + DesignBounds parentBounds, + DesignControlNode? parentNode, + DesignerLayoutResult layout) + { + var parentOwnsChildPlacement = parentNode is null + || (!DesignerSpecialContainers.IsFlowLayoutPanel(parentNode) + && !DesignerSpecialContainers.IsTableLayoutPanel(parentNode) + && !DesignerSpecialContainers.IsSplitContainer(parentNode) + && !DesignerSpecialContainers.IsTabControl(parentNode)); + + foreach (var child in children) + { + var absoluteBounds = layout.GetEffectiveBounds(child); + + if (parentOwnsChildPlacement && !DesignerLayoutProperties.IsDocked(child)) + { + child.Bounds = new DesignBounds( + absoluteBounds.X - parentBounds.X, + absoluteBounds.Y - parentBounds.Y, + absoluteBounds.Width, + absoluteBounds.Height); + } + + PersistAnchoredBounds(child.Children, absoluteBounds, child, layout); + } + } + private static DesignBounds ConsumeDockSpace( DesignControlNode node, DesignBounds remaining, @@ -362,7 +546,10 @@ private static DesignBounds ConsumeDockSpace( DockStyle.Bottom => new DesignBounds(remaining.X, remaining.Y, remaining.Width, Math.Max(0, remaining.Height - usedBounds.Height)), DockStyle.Left => new DesignBounds(remaining.X + usedBounds.Width, remaining.Y, Math.Max(0, remaining.Width - usedBounds.Width), remaining.Height), DockStyle.Right => new DesignBounds(remaining.X, remaining.Y, Math.Max(0, remaining.Width - usedBounds.Width), remaining.Height), - DockStyle.Fill => new DesignBounds(remaining.X, remaining.Y, 0, 0), + // Fill uses the current remaining rectangle but, like the production runtime engine, + // does not consume it. A later front-to-back edge dock can still reserve space and a + // subsequent layout pass will resize the fill control to the final remainder. + DockStyle.Fill => remaining, _ => remaining }; } diff --git a/ModernFormsNext.Designer/Surface/Layout/DesignerLayoutProperties.cs b/ModernFormsNext.Designer/Surface/Layout/DesignerLayoutProperties.cs index 7cd9203..4162316 100644 --- a/ModernFormsNext.Designer/Surface/Layout/DesignerLayoutProperties.cs +++ b/ModernFormsNext.Designer/Surface/Layout/DesignerLayoutProperties.cs @@ -15,6 +15,14 @@ internal static class DesignerLayoutProperties public const string PaddingPropertyName = "Padding"; + public const string MarginPropertyName = "Margin"; + + public const string MinimumSizePropertyName = "MinimumSize"; + + public const string MaximumSizePropertyName = "MaximumSize"; + + public const string VisiblePropertyName = "Visible"; + public static DockStyle GetDock(DesignControlNode node) { if (!node.Properties.TryGetValue(DockPropertyName, out var value)) @@ -65,20 +73,78 @@ public static AnchorStyles GetAnchor(DesignControlNode node) public static Padding GetPadding(DesignControlNode node) => GetPadding(node.Properties); + public static Padding GetMargin(DesignControlNode node) + => GetPadding(node.Properties, MarginPropertyName, new Padding(3)); + public static Padding GetPadding(IReadOnlyDictionary properties) + => GetPadding(properties, PaddingPropertyName, Padding.Empty); + + public static bool IsVisible(DesignControlNode node) { - if (!properties.TryGetValue(PaddingPropertyName, out var value)) - return Padding.Empty; + if (!node.Properties.TryGetValue(VisiblePropertyName, out var value)) + return true; + + return value.Kind switch + { + DesignPropertyValueKind.Boolean when value.Value is bool visible => visible, + DesignPropertyValueKind.String when bool.TryParse(value.ToString(), out var visible) => visible, + _ => true + }; + } + + public static Size GetMinimumSize(DesignControlNode node) + => GetSize(node, MinimumSizePropertyName); + + public static Size GetMaximumSize(DesignControlNode node) + => GetSize(node, MaximumSizePropertyName); + + public static DesignBounds ApplySizeConstraints(DesignControlNode node, DesignBounds bounds) + { + var minimum = GetMinimumSize(node); + var maximum = GetMaximumSize(node); + var width = maximum.Width > 0 ? Math.Min(bounds.Width, maximum.Width) : bounds.Width; + var height = maximum.Height > 0 ? Math.Min(bounds.Height, maximum.Height) : bounds.Height; + + width = Math.Max(width, minimum.Width); + height = Math.Max(height, minimum.Height); + + return new DesignBounds(bounds.X, bounds.Y, Math.Max(0, width), Math.Max(0, height)); + } + + private static Padding GetPadding( + IReadOnlyDictionary properties, + string propertyName, + Padding defaultValue) + { + if (!properties.TryGetValue(propertyName, out var value)) + return defaultValue; try { return DesignerPropertyValueEditor.FromDesignPropertyValue(value, typeof(Padding)) is Padding padding ? padding - : Padding.Empty; + : defaultValue; + } + catch (Exception exception) when (exception is FormatException or InvalidCastException or OverflowException) + { + return defaultValue; + } + } + + private static Size GetSize(DesignControlNode node, string propertyName) + { + if (!node.Properties.TryGetValue(propertyName, out var value)) + return Size.Empty; + + try + { + return DesignerPropertyValueEditor.FromDesignPropertyValue(value, typeof(Size)) is Size size + ? size + : Size.Empty; } catch (Exception exception) when (exception is FormatException or InvalidCastException or OverflowException) { - return Padding.Empty; + return Size.Empty; } } diff --git a/ModernFormsNext/ScrollableControl.cs b/ModernFormsNext/ScrollableControl.cs index 4796c76..b6445aa 100644 --- a/ModernFormsNext/ScrollableControl.cs +++ b/ModernFormsNext/ScrollableControl.cs @@ -21,6 +21,7 @@ public class ScrollableControl : Control private bool auto_scroll = false; private readonly bool force_hscroll_visible = false; private readonly bool force_vscroll_visible = false; + private bool preserve_anchor_layout_during_scrollbar_adjustment; /// /// Initializes a new instance of the ScrollableControl class. @@ -52,7 +53,8 @@ public ScrollableControl () /// /// Adjusts the scrollbars based on the currently contained controls. /// - protected virtual void AdjustFormScrollbars (bool displayScrollbars) => Recalculate (false); + protected virtual void AdjustFormScrollbars (bool displayScrollbars) + => Recalculate (preserve_anchor_layout_during_scrollbar_adjustment); /// /// Gets or sets a value indicating the user can scroll to controls beyond the ScrollableControl's bounds. @@ -194,7 +196,23 @@ private void HandleScroll (object? sender, EventArgs e) protected override void OnLayout (LayoutEventArgs e) { CalculateCanvasSize (); - AdjustFormScrollbars (AutoScroll); + + // A Bounds layout runs after the client size has already changed. Keep the existing + // anchor distances until DefaultLayout consumes them below; ResumeLayout(false) + // would otherwise reinitialize every explicit child against the new client size. + // Other layouts retain the established behavior, including presentation-padding + // transitions where anchored explicit bounds intentionally stay unchanged. + var previousPreserveAnchorLayout = preserve_anchor_layout_during_scrollbar_adjustment; + preserve_anchor_layout_during_scrollbar_adjustment = string.Equals ( + e.AffectedProperty, + PropertyNames.Bounds, + StringComparison.Ordinal); + + try { + AdjustFormScrollbars (AutoScroll); + } finally { + preserve_anchor_layout_during_scrollbar_adjustment = previousPreserveAnchorLayout; + } base.OnLayout (e); } diff --git a/docs/architecture/designer-runtime-layout-parity.md b/docs/architecture/designer-runtime-layout-parity.md new file mode 100644 index 0000000..f24f19f --- /dev/null +++ b/docs/architecture/designer-runtime-layout-parity.md @@ -0,0 +1,99 @@ +# Designer/runtime layout parity + +The Designer/runtime layout parity suite protects the semantic geometry stored in `.mfdesign` +documents. It compares the production `DesignerLayoutEngine` result with an equivalent tree of +real ModernFormsNext controls laid out by the runtime engine. The suite is headless and compares +structured values; it does not render or compare bitmap snapshots. + +## Source of truth and execution paths + +Runtime layout is the semantic source of truth. The runtime side creates the same controls and +properties that generated `InitializeComponent` code creates, attaches parents before descendants, +and invokes the public `Control.PerformLayout` path. This exercises `DefaultLayout`, +`FlowLayoutPanel`, `TableLayoutPanel`, `ScrollableControl.DisplayRectangle`, constraints, and real +control collection order. The harness does not contain a second Dock or Anchor algorithm. + +The Designer side invokes `DesignerLayoutEngine.Layout` on the equivalent `DesignDocument`. +Property-edit scenarios commit text through `DesignerPropertyGridState`, root resize scenarios use +the same `DesignerSession` path as the property grid, and hit-test checks use +`DesignerHitTestService`. Representative pipeline tests also serialize and reopen `.mfdesign`, +compile and instantiate generated `.Designer.cs`, and reverse-parse generated code. + +The internal test model records one `LayoutNodeSnapshot` per stable name path, for example +`ParityControl.card.content.button1`. A node contains: + +- type and absolute logical `Bounds`; +- client and display rectangles for containers; +- ancestor-clipped visible bounds; +- the rectangle size available to children. + +Integer geometry must match exactly. A failure identifies the scenario, node path, property, +runtime value, and Designer value. There is no general epsilon. + +## Covered matrix + +The fast matrix in `DesignerRuntimeLayoutParityTests` covers: + +- ordinary children and all Dock edges, Fill, mixed/repeated edge sequences, and child + reorder/remove/add behavior; +- zero, uniform, asymmetric, negative-normalized, nested, and UserControl-root Padding, including + the issue #31 asymmetric Padding regression; +- default and asymmetric Margin where the runtime container uses Margin; +- all representative Anchor combinations, parent width/height/both resize, and repeated root + resize without persisted drift; +- MinimumSize, MaximumSize, combined constraints, and constrained Dock.Fill; +- nested Panel and UserControl chains, a UserControl used as a child, and Form and UserControl + roots; +- basic left-to-right, top-down, and wrapped FlowLayoutPanel behavior, plus the supported default + auto-strip TableLayoutPanel subset; +- hidden and toggled controls, hidden Dock controls, clipping, and Z-order; +- Property Grid edits for Padding, Margin, Dock, Anchor, location, size, constraints, and visibility; +- logical Shape bounds with a deterministic zero-duration LayoutTransition; +- logical-to-device edge conversion at 100%, 125%, 150%, and 200% DPI; +- representative save/reopen, generated-code execution, reverse-parser, and final-geometry hit-test + cases. + +Form documents store the editable client surface size, so generated Forms use `ClientSize`. +UserControl roots continue to generate `Size`. This distinction avoids comparing Form window chrome +with Designer client geometry. + +## Logical and presentation geometry + +The suite compares the committed logical layout target exposed through runtime bounds. It does not +compare an interpolated presentation rectangle, rendered transforms, selection handles, adorners, +the dotted grid, or the Designer Form-title mockup. Animation coverage uses a zero-duration layout +transition so no clock, timer, active window, or `Thread.Sleep` can affect the result. + +## Adding a scenario + +1. Add a small `DesignDocument` factory to `DesignerRuntimeLayoutParityTests` and register it in + `CoreParityScenarios`, or in one of the representative pipeline data sources. +2. Use stable, unique control names. Keep authored bounds and properties equivalent to generated + runtime initialization. +3. Extend the harness control factory only when the production control type adds meaningful layout + semantics. Do not implement that control's layout inside the harness. +4. Prefer exact structured assertions. If a representation cannot be normalized without hiding a + real difference, document the boundary and keep a focused regression outside the equality + matrix. +5. Run the focused parity filter, all Designer tests, and the full repository validation. + +## Known exclusions and follow-up boundaries + +- Oversized Padding can produce negative runtime `DisplayRectangle` dimensions, while + `DesignBounds` intentionally cannot represent negative width or height. The focused + `OversizedPaddingDoesNotProduceNegativeDesignerBounds` regression preserves the Designer safety + contract; this case is not normalized into a false equality assertion. +- Full FlowLayoutPanel/TableLayoutPanel style matrices, AutoSize combinations, spanning edge + cases, and right-to-left layout remain outside the fast foundation. In particular, complete RTL + parity remains tracked by issue #89. +- Visual editing of inherited Forms and UserControls remains tracked by issue #39. The parity + harness should add inherited scenarios when that production path exists; it does not emulate the + missing feature. +- Project UserControls remain data-only atomic preview boundaries. Arbitrary application code is + not loaded to obtain parity. +- Designer-wide undo/redo transactions remain issue #33. Current property-grid cases validate the + resulting layout state; after #33 they should also be reused to verify undo and redo geometry. +- Monitor-derived DPI, interactive Visual Studio hosting, rendered pixels, accessibility, and + platform-device observation are separate validation layers. + +These exclusions are explicit extension points, not alternate layout implementations. diff --git a/docs/known-limitations.md b/docs/known-limitations.md index 77c2229..53ed25e 100644 --- a/docs/known-limitations.md +++ b/docs/known-limitations.md @@ -43,7 +43,7 @@ not active limitations. | DES-07 | Designer | Visual editing of inherited custom Forms and UserControls is not implemented. | Missing feature | High | [Tracked #39](https://github.com/ProGraMajster/ModernFormsNext/issues/39) | [UserControls](user-controls.md#current-design-time-boundaries) | | DES-08 | Designer | Arbitrary application code is not executed for preview. Project UserControls use data-only projections and unsupported executable controls use safe placeholders. | Known design limitation | High | Intentional safety boundary; broader isolation [tracked #40](https://github.com/ProGraMajster/ModernFormsNext/issues/40) | [Safe preview](designer-architecture.md#safe-embedded-usercontrol-preview) | | DES-09 | Designer | Auto-save exists, but crash recovery and external-change conflict handling do not. | Missing feature | High | [Tracked #41](https://github.com/ProGraMajster/ModernFormsNext/issues/41) | [Designer architecture](designer-architecture.md#current-designer-limitations) | -| DES-10 | Designer/layout | Padding, order, DPI, Shape, animation, and UserControl paths have focused tests, but there is no comprehensive Designer/runtime parity suite. | Validation gap | High | [Tracked #42](https://github.com/ProGraMajster/ModernFormsNext/issues/42) | [Designer architecture](designer-architecture.md#current-designer-limitations) | +| DES-10 | Designer/layout | The previous lack of a comprehensive Designer/runtime semantic layout parity suite is resolved by a structured, headless matrix; documented Flow/Table, inherited-root, and oversized-Padding representation boundaries remain explicit follow-ups. | Resolved after 1.10.0 | Low | Implementation and regression coverage [#42](https://github.com/ProGraMajster/ModernFormsNext/issues/42) | [Parity architecture](architecture/designer-runtime-layout-parity.md) | | DES-11 | Visual Studio integration | The VSIX HWND adapter still uses reflection for the runtime handle and lacks built-in View Designer/Shift+F7 and dependent-file automation. | Tooling limitation | Medium | [Tracked #71](https://github.com/ProGraMajster/ModernFormsNext/issues/71) | [Visual Studio integration](designer-architecture.md#current-visual-studio-integration-boundaries) | | DES-12 | Custom controls | Toolbox discovery is source-only and refreshed by reopening the Designer; binary-only controls and source-discovered custom properties/events are not a complete metadata surface. | Tooling limitation | High | [Tracked #68](https://github.com/ProGraMajster/ModernFormsNext/issues/68) | [UserControls](user-controls.md#current-design-time-boundaries) | | GEO-01 | Shape/Geometry | No arc segment, geometry group/boolean operations, SVG import/core path string, general Stretch contract, generic Control geometry clip, or graphical Bezier editor. | Missing feature | Medium | [Tracked #70](https://github.com/ProGraMajster/ModernFormsNext/issues/70) | [Shapes](shapes-and-vector-geometry.md#current-limitations) |