From 4d13770b349c4e8a47ad46704e3bebbce3626ad1 Mon Sep 17 00:00:00 2001 From: Nikita Tsukanov Date: Thu, 26 Mar 2026 18:19:17 +0500 Subject: [PATCH] Introduced "forced" CSD mode without app opting in (#20976) * Introduced "forced" CSD mode without app opting in * C is for Consistency * api diff * [X11] Better handling of forced-vs-app-triggeed CSD * Round WindowDrawnDecorations sizes to be pixel-aligned --- api/Avalonia.nupkg.xml | 12 + .../Chrome/WindowDrawnDecorations.cs | 27 +- .../PresentationSource.RenderRoot.cs | 5 +- .../PresentationSource/PresentationSource.cs | 4 +- src/Avalonia.Controls/TopLevel.cs | 2 +- src/Avalonia.Controls/TopLevelHost.cs | 87 +++++++ src/Avalonia.Controls/Window.cs | 99 +++++++- src/Avalonia.Controls/WindowBase.cs | 2 +- src/Avalonia.X11/X11Platform.cs | 31 ++- src/Avalonia.X11/X11Window.cs | 19 +- src/Windows/Avalonia.Win32/WindowImpl.cs | 1 + .../WindowTests.cs | 231 ++++++++++++++++++ 12 files changed, 488 insertions(+), 32 deletions(-) diff --git a/api/Avalonia.nupkg.xml b/api/Avalonia.nupkg.xml index 0e767b5ef3..71bdf3714d 100644 --- a/api/Avalonia.nupkg.xml +++ b/api/Avalonia.nupkg.xml @@ -2395,6 +2395,12 @@ baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll current/Avalonia/lib/net10.0/Avalonia.Controls.dll + + CP0002 + M:Avalonia.Controls.Window.ExtendClientAreaToDecorationsChanged(System.Boolean) + baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll + current/Avalonia/lib/net10.0/Avalonia.Controls.dll + CP0002 M:Avalonia.Controls.Window.get_ExtendClientAreaChromeHints @@ -4063,6 +4069,12 @@ baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll current/Avalonia/lib/net8.0/Avalonia.Controls.dll + + CP0002 + M:Avalonia.Controls.Window.ExtendClientAreaToDecorationsChanged(System.Boolean) + baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll + current/Avalonia/lib/net8.0/Avalonia.Controls.dll + CP0002 M:Avalonia.Controls.Window.get_ExtendClientAreaChromeHints diff --git a/src/Avalonia.Controls/Chrome/WindowDrawnDecorations.cs b/src/Avalonia.Controls/Chrome/WindowDrawnDecorations.cs index 48847b5f59..7a282bf306 100644 --- a/src/Avalonia.Controls/Chrome/WindowDrawnDecorations.cs +++ b/src/Avalonia.Controls/Chrome/WindowDrawnDecorations.cs @@ -2,6 +2,7 @@ using System; using Avalonia.Automation; using Avalonia.Controls.Metadata; using Avalonia.Controls.Primitives; +using Avalonia.Layout; using Avalonia.LogicalTree; using Avalonia.Reactive; using Avalonia.Styling; @@ -138,6 +139,7 @@ public class WindowDrawnDecorations : StyledElement private IDisposable? _windowSubscriptions; private Window? _hostWindow; private double _titleBarHeightOverride = -1; + private double _renderScaling = 1.0; /// /// Raised when any property affecting the effective geometry changes @@ -145,6 +147,22 @@ public class WindowDrawnDecorations : StyledElement /// internal event Action? EffectiveGeometryChanged; + /// + /// Gets or sets the current render scaling factor used for pixel-aligning + /// decoration geometry (title bar height, frame/shadow thickness). + /// + internal double RenderScaling + { + get => _renderScaling; + set + { + if (_renderScaling == value) + return; + _renderScaling = value; + UpdateEffectiveGeometry(); + } + } + /// /// Gets or sets the decorations template. /// @@ -559,16 +577,19 @@ public class WindowDrawnDecorations : StyledElement private void UpdateEffectiveGeometry() { + var scale = _renderScaling; + TitleBarHeight = EnabledParts.HasFlag(DrawnWindowDecorationParts.TitleBar) - ? (TitleBarHeightOverride == -1 ? DefaultTitleBarHeight : TitleBarHeightOverride) + ? LayoutHelper.RoundLayoutValue( + TitleBarHeightOverride == -1 ? DefaultTitleBarHeight : TitleBarHeightOverride, scale) : 0; FrameThickness = EnabledParts.HasFlag(DrawnWindowDecorationParts.Border) - ? (FrameThicknessOverride ?? DefaultFrameThickness) + ? LayoutHelper.RoundLayoutThickness(FrameThicknessOverride ?? DefaultFrameThickness, scale) : default; ShadowThickness = EnabledParts.HasFlag(DrawnWindowDecorationParts.Shadow) - ? (ShadowThicknessOverride ?? DefaultShadowThickness) + ? LayoutHelper.RoundLayoutThickness(ShadowThicknessOverride ?? DefaultShadowThickness, scale) : default; EffectiveGeometryChanged?.Invoke(); diff --git a/src/Avalonia.Controls/PresentationSource/PresentationSource.RenderRoot.cs b/src/Avalonia.Controls/PresentationSource/PresentationSource.RenderRoot.cs index 7c129742b8..2a9e833a48 100644 --- a/src/Avalonia.Controls/PresentationSource/PresentationSource.RenderRoot.cs +++ b/src/Avalonia.Controls/PresentationSource/PresentationSource.RenderRoot.cs @@ -1,3 +1,4 @@ +using Avalonia.Input; using System; using Avalonia.Layout; using Avalonia.Rendering; @@ -7,7 +8,6 @@ namespace Avalonia.Controls; internal partial class PresentationSource { - private readonly Func _clientSizeProvider; public CompositingRenderer Renderer { get; } IRenderer IPresentationSource.Renderer => Renderer; Visual IPresentationSource.RootVisual => RootVisual; @@ -16,8 +16,7 @@ internal partial class PresentationSource public IHitTester? HitTesterOverride { get; set; } public double RenderScaling { get; private set; } = 1.0; - - public Size ClientSize => _clientSizeProvider(); + public Size ClientSize => PlatformImpl?.ClientSize ?? default; public void SceneInvalidated(object? sender, SceneInvalidatedEventArgs sceneInvalidatedEventArgs) { diff --git a/src/Avalonia.Controls/PresentationSource/PresentationSource.cs b/src/Avalonia.Controls/PresentationSource/PresentationSource.cs index aad1bc1003..8f298d2e30 100644 --- a/src/Avalonia.Controls/PresentationSource/PresentationSource.cs +++ b/src/Avalonia.Controls/PresentationSource/PresentationSource.cs @@ -23,10 +23,8 @@ internal partial class PresentationSource : IPresentationSource, IInputRoot, IDi public PresentationSource(InputElement rootVisual, InputElement defaultFocusVisual, ITopLevelImpl platformImpl, - IAvaloniaDependencyResolver dependencyResolver, Func clientSizeProvider) + IAvaloniaDependencyResolver dependencyResolver) { - _clientSizeProvider = clientSizeProvider; - PlatformImpl = platformImpl; diff --git a/src/Avalonia.Controls/TopLevel.cs b/src/Avalonia.Controls/TopLevel.cs index 69d32d2b56..44b82c886f 100644 --- a/src/Avalonia.Controls/TopLevel.cs +++ b/src/Avalonia.Controls/TopLevel.cs @@ -212,7 +212,7 @@ namespace Avalonia.Controls LogicalChildren.Add(hostVisual); _source = new PresentationSource(hostVisual, this, - impl, dependencyResolver, () => ClientSize); + impl, dependencyResolver); _source.Renderer.SceneInvalidated += SceneInvalidated; _scaling = LayoutHelper.ValidateScaling(impl.RenderScaling); diff --git a/src/Avalonia.Controls/TopLevelHost.cs b/src/Avalonia.Controls/TopLevelHost.cs index d2d3ddf8fa..9592dd8221 100644 --- a/src/Avalonia.Controls/TopLevelHost.cs +++ b/src/Avalonia.Controls/TopLevelHost.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using Avalonia.Automation.Peers; using Avalonia.Controls.Chrome; using Avalonia.Input; +using Avalonia.Layout; using Avalonia.LogicalTree; using Avalonia.Reactive; @@ -14,6 +15,8 @@ namespace Avalonia.Controls; /// internal partial class TopLevelHost : Control { + private Thickness _decorationInset; + static TopLevelHost() { KeyboardNavigation.TabNavigationProperty.OverrideDefaultValue(KeyboardNavigationMode.Cycle); @@ -25,5 +28,89 @@ internal partial class TopLevelHost : Control VisualChildren.Add(tl); } + /// + /// Gets or sets the decoration inset applied to the TopLevel child in forced decoration mode. + /// When non-zero, the TopLevel is measured and arranged within the inset area while + /// decoration layers use the full available size. + /// + internal Thickness DecorationInset + { + get => _decorationInset; + set + { + if (_decorationInset == value) + return; + _decorationInset = value; + InvalidateMeasure(); + } + } + + protected override Size MeasureOverride(Size availableSize) + { + var inset = _decorationInset; + var hasInset = inset != default; + var desiredSize = default(Size); + + foreach (var child in VisualChildren) + { + if (child is Layoutable l) + { + if (hasInset && ReferenceEquals(child, _topLevel)) + { + // In forced mode, measure the TopLevel with reduced size + var contentSize = new Size( + Math.Max(0, availableSize.Width - inset.Left - inset.Right), + Math.Max(0, availableSize.Height - inset.Top - inset.Bottom)); + l.Measure(contentSize); + + // Add inset back so TopLevelHost's desired size represents the full frame. + // This ensures ArrangeOverride receives the full frame size and can correctly + // position the TopLevel within the inset area. + desiredSize = new Size( + Math.Max(desiredSize.Width, l.DesiredSize.Width + inset.Left + inset.Right), + Math.Max(desiredSize.Height, l.DesiredSize.Height + inset.Top + inset.Bottom)); + } + else + { + l.Measure(availableSize); + + desiredSize = new Size( + Math.Max(desiredSize.Width, l.DesiredSize.Width), + Math.Max(desiredSize.Height, l.DesiredSize.Height)); + } + } + } + + return desiredSize; + } + + protected override Size ArrangeOverride(Size finalSize) + { + var inset = _decorationInset; + var hasInset = inset != default; + + foreach (var child in VisualChildren) + { + if (child is Layoutable l) + { + if (hasInset && ReferenceEquals(child, _topLevel)) + { + // In forced mode, arrange the TopLevel within the inset area + var contentSize = new Size( + Math.Max(0, finalSize.Width - inset.Left - inset.Right), + Math.Max(0, finalSize.Height - inset.Top - inset.Bottom)); + + l.Arrange(new Rect(inset.Left, inset.Top, contentSize.Width, contentSize.Height)); + } + else + { + l.Arrange(new Rect(finalSize)); + } + } + } + + return finalSize; + } + protected override bool BypassFlowDirectionPolicies => true; } diff --git a/src/Avalonia.Controls/Window.cs b/src/Avalonia.Controls/Window.cs index 80349634b7..4dab3574eb 100644 --- a/src/Avalonia.Controls/Window.cs +++ b/src/Avalonia.Controls/Window.cs @@ -98,6 +98,7 @@ namespace Avalonia.Controls private Thickness _offScreenMargin; private bool _canHandleResized = false; private Size _arrangeBounds; + private bool _isForcedDecorationMode; /// /// Defines the property. @@ -249,7 +250,11 @@ namespace Avalonia.Controls impl.WindowStateChanged = HandleWindowStateChanged; _maxPlatformClientSize = PlatformImpl?.MaxAutoSizeHint ?? default(Size); impl.ExtendClientAreaToDecorationsChanged = ExtendClientAreaToDecorationsChanged; - this.GetObservable(ClientSizeProperty).Skip(1).Subscribe(x => PlatformImpl?.Resize(x, WindowResizeReason.Application)); + this.GetObservable(ClientSizeProperty).Skip(1).Subscribe(x => + { + ResizePlatformImpl(x, WindowResizeReason.Application); + }); + ScalingChanged += OnScalingChangedUpdateDecorations; CreatePlatformImplBinding(TitleProperty, title => PlatformImpl!.SetTitle(title)); CreatePlatformImplBinding(IconProperty, SetEffectiveIcon); @@ -668,7 +673,7 @@ namespace Avalonia.Controls UpdateDrawnDecorationParts(); } - protected virtual void ExtendClientAreaToDecorationsChanged(bool isExtended) + private void ExtendClientAreaToDecorationsChanged(bool isExtended) { IsExtendedIntoWindowDecorations = isExtended; OffScreenMargin = PlatformImpl?.OffScreenMargin ?? default; @@ -679,6 +684,10 @@ namespace Avalonia.Controls private void UpdateDrawnDecorations() { var parts = ComputeDecorationParts(); + + // Detect forced mode: platform needs managed decorations but app hasn't opted in + _isForcedDecorationMode = parts != null && !IsExtendedIntoWindowDecorations; + TopLevelHost.UpdateDrawnDecorations(parts, WindowState); if (parts != null) @@ -687,6 +696,8 @@ namespace Avalonia.Controls var decorations = TopLevelHost.Decorations; if (decorations != null) { + decorations.RenderScaling = RenderScaling; + var hint = ExtendClientAreaTitleBarHeightHint; if (hint >= 0) decorations.TitleBarHeightOverride = hint; @@ -696,6 +707,13 @@ namespace Avalonia.Controls UpdateDrawnDecorationMargins(); } + private void OnScalingChangedUpdateDecorations(object? sender, EventArgs e) + { + var decorations = TopLevelHost.Decorations; + if (decorations != null) + decorations.RenderScaling = RenderScaling; + } + /// /// Updates decoration parts based on current window state without /// re-creating the decorations instance. @@ -753,7 +771,9 @@ namespace Avalonia.Controls var decorations = TopLevelHost.Decorations; if (decorations == null) { + // Only use platform margins if drawn decorations are not active WindowDecorationMargin = PlatformImpl?.ExtendedMargins ?? default; + TopLevelHost.DecorationInset = default; return; } @@ -764,11 +784,25 @@ namespace Avalonia.Controls ? decorations.FrameThickness : default; var shadow = parts.HasFlag(Chrome.DrawnWindowDecorationParts.Shadow) ? decorations.ShadowThickness : default; - WindowDecorationMargin = new Thickness( + var margin = new Thickness( frame.Left + shadow.Left, titleBarHeight + frame.Top + shadow.Top, frame.Right + shadow.Right, frame.Bottom + shadow.Bottom); + + if (_isForcedDecorationMode) + { + // In forced mode, app is unaware of decorations. + // TopLevelHost insets the Window child; WindowDecorationMargin stays zero. + WindowDecorationMargin = default; + TopLevelHost.DecorationInset = margin; + } + else + { + // In extended mode, app handles the margin itself. + WindowDecorationMargin = margin; + TopLevelHost.DecorationInset = default; + } } private void OnTitleBarHeightHintChanged() @@ -933,6 +967,15 @@ namespace Avalonia.Controls // Enable drawn decorations before layout so margins are computed UpdateDrawnDecorations(); + // In forced mode, adjust ClientSize to reflect usable content area + if (_isForcedDecorationMode) + { + var inset = TopLevelHost.DecorationInset; + ClientSize = new Size( + Math.Max(0, ClientSize.Width - inset.Left - inset.Right), + Math.Max(0, ClientSize.Height - inset.Top - inset.Bottom)); + } + _shown = true; IsVisible = true; @@ -978,10 +1021,18 @@ namespace Avalonia.Controls DesktopScalingOverride = null; - if (clientSizeChanged || ClientSize != PlatformImpl?.ClientSize) + // In forced mode, compare against adjusted platform size + var platformClientSize = PlatformImpl?.ClientSize ?? default; + var comparableClientSize = _isForcedDecorationMode + ? new Size( + Math.Max(0, platformClientSize.Width - TopLevelHost.DecorationInset.Left - TopLevelHost.DecorationInset.Right), + Math.Max(0, platformClientSize.Height - TopLevelHost.DecorationInset.Top - TopLevelHost.DecorationInset.Bottom)) + : platformClientSize; + + if (clientSizeChanged || ClientSize != comparableClientSize) { // Previously it was called before ExecuteInitialLayoutPass - PlatformImpl?.Resize(ClientSize, WindowResizeReason.Layout); + ResizePlatformImpl(ClientSize, WindowResizeReason.Layout); // we do not want PlatformImpl?.Resize to trigger HandleResized yet because it will set Width and Height. // So perform some important actions from HandleResized @@ -1037,6 +1088,22 @@ namespace Avalonia.Controls } } + private void ResizePlatformImpl(Size size, WindowResizeReason reason) + { + // In forced mode, add decoration inset so platform gets full frame size + if (_isForcedDecorationMode) + { + var inset = TopLevelHost.DecorationInset; + size = new Size( + size.Width + inset.Left + inset.Right, + size.Height + inset.Top + inset.Bottom); + if (PlatformImpl?.ClientSize != size) + PlatformImpl?.Resize(size, reason); + } + else + PlatformImpl?.Resize(size, reason); + } + /// /// Shows the window as a dialog. /// @@ -1272,6 +1339,14 @@ namespace Avalonia.Controls { var sizeToContent = SizeToContent; var clientSize = ClientSize; + if (_isForcedDecorationMode) + { + clientSize = PlatformImpl?.ClientSize ?? clientSize; + var inset = TopLevelHost.DecorationInset; + clientSize = new Size( + Math.Max(0, clientSize.Width - inset.Left - inset.Right), + Math.Max(0, clientSize.Height - inset.Top - inset.Bottom)); + } var maxAutoSize = PlatformImpl?.MaxAutoSizeHint ?? Size.Infinity; var useAutoWidth = sizeToContent.HasAllFlags(SizeToContent.Width); var useAutoHeight = sizeToContent.HasAllFlags(SizeToContent.Height); @@ -1332,7 +1407,9 @@ namespace Avalonia.Controls { _arrangeBounds = size; if (_canHandleResized) - PlatformImpl?.Resize(size, WindowResizeReason.Layout); + { + ResizePlatformImpl(size, WindowResizeReason.Layout); + } return ClientSize; } @@ -1350,6 +1427,16 @@ namespace Avalonia.Controls /// internal override void HandleResized(Size clientSize, WindowResizeReason reason) { + // In forced decoration mode, the platform's clientSize includes decoration area. + // Subtract the decoration inset so Window.ClientSize reflects the usable content area. + if (_isForcedDecorationMode) + { + var inset = TopLevelHost.DecorationInset; + clientSize = new Size( + Math.Max(0, clientSize.Width - inset.Left - inset.Right), + Math.Max(0, clientSize.Height - inset.Top - inset.Bottom)); + } + if (_canHandleResized && (ClientSize != clientSize || double.IsNaN(Width) || double.IsNaN(Height))) { var sizeToContent = SizeToContent; diff --git a/src/Avalonia.Controls/WindowBase.cs b/src/Avalonia.Controls/WindowBase.cs index 07966600e6..894b7ca6e6 100644 --- a/src/Avalonia.Controls/WindowBase.cs +++ b/src/Avalonia.Controls/WindowBase.cs @@ -304,7 +304,7 @@ namespace Avalonia.Controls { var constraint = ArrangeSetBounds(finalRect.Size); var arrangeSize = ArrangeOverride(constraint); - Bounds = new Rect(arrangeSize); + Bounds = new Rect(finalRect.Position, arrangeSize); } /// diff --git a/src/Avalonia.X11/X11Platform.cs b/src/Avalonia.X11/X11Platform.cs index 566b0d907a..a3f98265f4 100644 --- a/src/Avalonia.X11/X11Platform.cs +++ b/src/Avalonia.X11/X11Platform.cs @@ -482,13 +482,32 @@ namespace Avalonia , Message = "Experimental, used mostly for testing" #endif )] - public bool? EnableDrawnDecorations - { - get => EnableDrawnDecorationsInternal; - set => EnableDrawnDecorationsInternal = value; - } + public bool? EnableDrawnDecorations { get; set; } + + internal bool EnableDrawnDecorationsInternal => +#pragma warning disable AVALONIA_X11_CSD + EnableDrawnDecorations == true || ForceDrawnDecorationsInternal; +#pragma warning restore AVALONIA_X11_CSD + + + /// + /// Forces client-side drawn window decorations on X11 for all windows, + /// even when the app has not opted in via ExtendClientAreaToDecorationsHint. + /// In this mode, Window.ClientSize reflects the usable content area + /// (platform client size minus decoration margins) and the app is unaware + /// of the decorations. + /// Implies EnableDrawnDecorations = true. + /// + [Experimental("AVALONIA_X11_FORCE_CSD" + #if NET10_0_OR_GREATER + , Message = "Experimental, used mostly for testing" + #endif + )] + public bool ForceDrawnDecorations { get; set; } - internal bool? EnableDrawnDecorationsInternal { get; set; } +#pragma warning disable AVALONIA_X11_FORCE_CSD + internal bool ForceDrawnDecorationsInternal => ForceDrawnDecorations; +#pragma warning restore AVALONIA_X11_FORCE_CSD /// /// If Avalonia is in control of a run loop, we propagate exceptions by stopping the run loop frame diff --git a/src/Avalonia.X11/X11Window.cs b/src/Avalonia.X11/X11Window.cs index 0cfa226163..23c5cc18db 100644 --- a/src/Avalonia.X11/X11Window.cs +++ b/src/Avalonia.X11/X11Window.cs @@ -926,7 +926,7 @@ namespace Avalonia.X11 mouse.Position = mouse.Position / RenderScaling; // Chrome hit-test for drawn decorations - if (_extendClientAreaToDecorations + if (UseManagedDecorations && mouse.Type == RawPointerEventType.LeftButtonDown && _inputRoot is { } inputRoot) { @@ -1187,8 +1187,8 @@ namespace Avalonia.X11 private void UpdateEffectiveSystemDecorations() { - // When extending client area, always hide WM decorations (we draw our own) - var effective = _extendClientAreaToDecorations + // When extending client area or forcing drawn decorations, always hide WM decorations (we draw our own) + var effective = UseManagedDecorations ? WindowDecorations.None : (_requestedWindowDecorations == WindowDecorations.Full ? WindowDecorations.Full @@ -1509,17 +1509,18 @@ namespace Avalonia.X11 } } - private bool _extendClientAreaToDecorations; + private bool _extendingClientAreaToDecorations; + private bool UseManagedDecorations => _extendingClientAreaToDecorations || _platform.Options.ForceDrawnDecorationsInternal; public void SetExtendClientAreaToDecorationsHint(bool extendIntoClientAreaHint) { - if (_platform.Options.EnableDrawnDecorationsInternal != true) + if (!_platform.Options.EnableDrawnDecorationsInternal) return; - if (_extendClientAreaToDecorations == extendIntoClientAreaHint) + if (_extendingClientAreaToDecorations == extendIntoClientAreaHint) return; - _extendClientAreaToDecorations = extendIntoClientAreaHint; + _extendingClientAreaToDecorations = extendIntoClientAreaHint; UpdateEffectiveSystemDecorations(); IsClientAreaExtendedToDecorations = extendIntoClientAreaHint; @@ -1606,10 +1607,10 @@ namespace Avalonia.X11 public AcrylicPlatformCompensationLevels AcrylicCompensationLevels { get; } = new AcrylicPlatformCompensationLevels(1, 0.8, 0.8); - public bool NeedsManagedDecorations => _extendClientAreaToDecorations; + public bool NeedsManagedDecorations => UseManagedDecorations; public PlatformRequestedDrawnDecoration RequestedDrawnDecorations => - _extendClientAreaToDecorations + UseManagedDecorations ? PlatformRequestedDrawnDecoration.Border | PlatformRequestedDrawnDecoration.ResizeGrips | PlatformRequestedDrawnDecoration.TitleBar diff --git a/src/Windows/Avalonia.Win32/WindowImpl.cs b/src/Windows/Avalonia.Win32/WindowImpl.cs index 0882516f57..9233cc2809 100644 --- a/src/Windows/Avalonia.Win32/WindowImpl.cs +++ b/src/Windows/Avalonia.Win32/WindowImpl.cs @@ -1188,6 +1188,7 @@ namespace Avalonia.Win32 { if (!_shown) { + ExtendClientAreaToDecorationsChanged?.Invoke(_isClientAreaExtended); return; } diff --git a/tests/Avalonia.Controls.UnitTests/WindowTests.cs b/tests/Avalonia.Controls.UnitTests/WindowTests.cs index 5347acbc33..63cc2db193 100644 --- a/tests/Avalonia.Controls.UnitTests/WindowTests.cs +++ b/tests/Avalonia.Controls.UnitTests/WindowTests.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Reactive.Linq; using System.Threading.Tasks; +using Avalonia.Controls.Platform; using Avalonia.Media; using Avalonia.Platform; using Avalonia.Threading; @@ -1164,6 +1165,236 @@ namespace Avalonia.Controls.UnitTests } } + public class ForcedDecorationSizingTests : ScopedTestBase + { + /// + /// Creates a mock IWindowImpl that simulates forced CSD mode: + /// NeedsManagedDecorations = true, RequestedDrawnDecorations includes TitleBar + Border, + /// but IsClientAreaExtendedToDecorations = false. + /// + private static Mock CreateForcedCsdWindowMock( + double initialWidth = 800, double initialHeight = 600) + { + var windowImpl = MockWindowingPlatform.CreateWindowMock(initialWidth, initialHeight); + + windowImpl.Setup(x => x.NeedsManagedDecorations).Returns(true); + windowImpl.Setup(x => x.RequestedDrawnDecorations).Returns( + PlatformRequestedDrawnDecoration.TitleBar | PlatformRequestedDrawnDecoration.Border); + + return windowImpl; + } + + [Fact] + public void ClientSize_Should_Exclude_Decoration_Inset() + { + using (UnitTestApplication.Start(TestServices.StyledWindow)) + { + var windowImpl = CreateForcedCsdWindowMock(); + var target = new Window(windowImpl.Object) + { + SizeToContent = SizeToContent.Manual, + }; + + // Verify mock setup + Assert.True(windowImpl.Object.NeedsManagedDecorations); + + target.Show(); + + var host = target.TopLevelHost; + var decorations = host.Decorations; + + // Debug: verify decorations were created + Assert.NotNull(decorations); + Assert.True(decorations!.TitleBarHeight > 0, + $"TitleBarHeight was {decorations.TitleBarHeight}"); + + var inset = host.DecorationInset; + Assert.NotEqual(default, inset); + + var expectedClientSize = new Size( + 800 - inset.Left - inset.Right, + 600 - inset.Top - inset.Bottom); + Assert.Equal(expectedClientSize, target.ClientSize); + } + } + + [Fact] + public void WindowDecorationMargin_Should_Be_Zero_In_Forced_Mode() + { + using (UnitTestApplication.Start(TestServices.StyledWindow)) + { + var windowImpl = CreateForcedCsdWindowMock(); + var target = new Window(windowImpl.Object) + { + SizeToContent = SizeToContent.Manual, + }; + + target.Show(); + + Assert.Equal(default(Thickness), target.WindowDecorationMargin); + } + } + + [Fact] + public void HandleResized_Should_Subtract_Inset_From_Platform_Size() + { + using (UnitTestApplication.Start(TestServices.StyledWindow)) + { + var windowImpl = CreateForcedCsdWindowMock(); + var target = new Window(windowImpl.Object) + { + SizeToContent = SizeToContent.Manual, + }; + + target.Show(); + + var inset = target.TopLevelHost.DecorationInset; + + // Simulate a platform resize (e.g. user resize) + target.PlatformImpl!.Resized!.Invoke(new Size(1000, 700), WindowResizeReason.User); + + var expectedClientSize = new Size( + 1000 - inset.Left - inset.Right, + 700 - inset.Top - inset.Bottom); + Assert.Equal(expectedClientSize, target.ClientSize); + } + } + + [Fact] + public void Setting_Width_Should_Resize_WindowImpl_With_Inset_Added() + { + using (UnitTestApplication.Start(TestServices.StyledWindow)) + { + var windowImpl = CreateForcedCsdWindowMock(); + var target = new Window(windowImpl.Object) + { + Width = 400, + Height = 300, + SizeToContent = SizeToContent.Manual, + }; + + target.Show(); + + var inset = target.TopLevelHost.DecorationInset; + + target.Width = 500; + target.LayoutManager.ExecuteLayoutPass(); + + // Platform should receive full frame size (content + inset) + var expectedPlatformSize = new Size( + 500 + inset.Left + inset.Right, + 300 + inset.Top + inset.Bottom); + windowImpl.Verify(x => x.Resize(expectedPlatformSize, WindowResizeReason.Layout)); + } + } + + [Fact] + public void Child_Should_Be_Measured_With_Content_Size() + { + using (UnitTestApplication.Start(TestServices.StyledWindow)) + { + var windowImpl = CreateForcedCsdWindowMock(); + var child = new ChildControl(); + var target = new Window(windowImpl.Object) + { + Width = 400, + Height = 300, + SizeToContent = SizeToContent.Manual, + Content = child, + }; + + target.Show(); + + Assert.Equal(1, child.MeasureSizes.Count); + Assert.Equal(new Size(400, 300), child.MeasureSizes[0]); + } + } + + [Fact] + public void Width_Height_Should_Not_Be_NaN_After_Show() + { + using (UnitTestApplication.Start(TestServices.StyledWindow)) + { + var windowImpl = CreateForcedCsdWindowMock(); + var target = new Window(windowImpl.Object) + { + SizeToContent = SizeToContent.Manual, + }; + + target.Show(); + + Assert.False(double.IsNaN(target.Width)); + Assert.False(double.IsNaN(target.Height)); + + var inset = target.TopLevelHost.DecorationInset; + Assert.Equal(800 - inset.Left - inset.Right, target.Width); + Assert.Equal(600 - inset.Top - inset.Bottom, target.Height); + } + } + + [Fact] + public void SizeToContent_Should_Work_In_Forced_Mode() + { + using (UnitTestApplication.Start(TestServices.StyledWindow)) + { + var windowImpl = CreateForcedCsdWindowMock(); + var child = new Canvas + { + Width = 400, + Height = 300, + }; + + var target = new Window(windowImpl.Object) + { + SizeToContent = SizeToContent.WidthAndHeight, + Content = child, + }; + + target.Show(); + + Assert.Equal(400, target.Width); + Assert.Equal(300, target.Height); + Assert.Equal(SizeToContent.WidthAndHeight, target.SizeToContent); + } + } + + [Fact] + public void User_Resize_Should_Reset_SizeToContent() + { + using (UnitTestApplication.Start(TestServices.StyledWindow)) + { + var windowImpl = CreateForcedCsdWindowMock(); + var child = new Canvas + { + Width = 400, + Height = 300, + }; + + var target = new Window(windowImpl.Object) + { + SizeToContent = SizeToContent.WidthAndHeight, + Content = child, + }; + + target.Show(); + Assert.Equal(400, target.Width); + Assert.Equal(300, target.Height); + + var inset = target.TopLevelHost.DecorationInset; + // Platform fires resize with full frame size + var newPlatformWidth = 500 + inset.Left + inset.Right; + var newPlatformHeight = 300 + inset.Top + inset.Bottom; + windowImpl.Object.Resized?.Invoke( + new Size(newPlatformWidth, newPlatformHeight), + WindowResizeReason.User); + + Assert.Equal(500, target.Width); + Assert.Equal(300, target.Height); + Assert.Equal(SizeToContent.Height, target.SizeToContent); + } + } + } + private static Mock CreateImpl() { var screen1 = new MockScreen(1.75, new PixelRect(new PixelSize(1920, 1080)), new PixelRect(new PixelSize(1920, 966)), true);