Browse Source

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
pull/20996/head
Nikita Tsukanov 7 months ago
committed by GitHub
parent
commit
4d13770b34
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 12
      api/Avalonia.nupkg.xml
  2. 27
      src/Avalonia.Controls/Chrome/WindowDrawnDecorations.cs
  3. 5
      src/Avalonia.Controls/PresentationSource/PresentationSource.RenderRoot.cs
  4. 4
      src/Avalonia.Controls/PresentationSource/PresentationSource.cs
  5. 2
      src/Avalonia.Controls/TopLevel.cs
  6. 87
      src/Avalonia.Controls/TopLevelHost.cs
  7. 99
      src/Avalonia.Controls/Window.cs
  8. 2
      src/Avalonia.Controls/WindowBase.cs
  9. 31
      src/Avalonia.X11/X11Platform.cs
  10. 19
      src/Avalonia.X11/X11Window.cs
  11. 1
      src/Windows/Avalonia.Win32/WindowImpl.cs
  12. 231
      tests/Avalonia.Controls.UnitTests/WindowTests.cs

12
api/Avalonia.nupkg.xml

@ -2395,6 +2395,12 @@
<Left>baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll</Left> <Left>baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll</Left>
<Right>current/Avalonia/lib/net10.0/Avalonia.Controls.dll</Right> <Right>current/Avalonia/lib/net10.0/Avalonia.Controls.dll</Right>
</Suppression> </Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Avalonia.Controls.Window.ExtendClientAreaToDecorationsChanged(System.Boolean)</Target>
<Left>baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll</Left>
<Right>current/Avalonia/lib/net10.0/Avalonia.Controls.dll</Right>
</Suppression>
<Suppression> <Suppression>
<DiagnosticId>CP0002</DiagnosticId> <DiagnosticId>CP0002</DiagnosticId>
<Target>M:Avalonia.Controls.Window.get_ExtendClientAreaChromeHints</Target> <Target>M:Avalonia.Controls.Window.get_ExtendClientAreaChromeHints</Target>
@ -4063,6 +4069,12 @@
<Left>baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll</Left> <Left>baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll</Left>
<Right>current/Avalonia/lib/net8.0/Avalonia.Controls.dll</Right> <Right>current/Avalonia/lib/net8.0/Avalonia.Controls.dll</Right>
</Suppression> </Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Avalonia.Controls.Window.ExtendClientAreaToDecorationsChanged(System.Boolean)</Target>
<Left>baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll</Left>
<Right>current/Avalonia/lib/net8.0/Avalonia.Controls.dll</Right>
</Suppression>
<Suppression> <Suppression>
<DiagnosticId>CP0002</DiagnosticId> <DiagnosticId>CP0002</DiagnosticId>
<Target>M:Avalonia.Controls.Window.get_ExtendClientAreaChromeHints</Target> <Target>M:Avalonia.Controls.Window.get_ExtendClientAreaChromeHints</Target>

27
src/Avalonia.Controls/Chrome/WindowDrawnDecorations.cs

@ -2,6 +2,7 @@ using System;
using Avalonia.Automation; using Avalonia.Automation;
using Avalonia.Controls.Metadata; using Avalonia.Controls.Metadata;
using Avalonia.Controls.Primitives; using Avalonia.Controls.Primitives;
using Avalonia.Layout;
using Avalonia.LogicalTree; using Avalonia.LogicalTree;
using Avalonia.Reactive; using Avalonia.Reactive;
using Avalonia.Styling; using Avalonia.Styling;
@ -138,6 +139,7 @@ public class WindowDrawnDecorations : StyledElement
private IDisposable? _windowSubscriptions; private IDisposable? _windowSubscriptions;
private Window? _hostWindow; private Window? _hostWindow;
private double _titleBarHeightOverride = -1; private double _titleBarHeightOverride = -1;
private double _renderScaling = 1.0;
/// <summary> /// <summary>
/// Raised when any property affecting the effective geometry changes /// Raised when any property affecting the effective geometry changes
@ -145,6 +147,22 @@ public class WindowDrawnDecorations : StyledElement
/// </summary> /// </summary>
internal event Action? EffectiveGeometryChanged; internal event Action? EffectiveGeometryChanged;
/// <summary>
/// Gets or sets the current render scaling factor used for pixel-aligning
/// decoration geometry (title bar height, frame/shadow thickness).
/// </summary>
internal double RenderScaling
{
get => _renderScaling;
set
{
if (_renderScaling == value)
return;
_renderScaling = value;
UpdateEffectiveGeometry();
}
}
/// <summary> /// <summary>
/// Gets or sets the decorations template. /// Gets or sets the decorations template.
/// </summary> /// </summary>
@ -559,16 +577,19 @@ public class WindowDrawnDecorations : StyledElement
private void UpdateEffectiveGeometry() private void UpdateEffectiveGeometry()
{ {
var scale = _renderScaling;
TitleBarHeight = EnabledParts.HasFlag(DrawnWindowDecorationParts.TitleBar) TitleBarHeight = EnabledParts.HasFlag(DrawnWindowDecorationParts.TitleBar)
? (TitleBarHeightOverride == -1 ? DefaultTitleBarHeight : TitleBarHeightOverride) ? LayoutHelper.RoundLayoutValue(
TitleBarHeightOverride == -1 ? DefaultTitleBarHeight : TitleBarHeightOverride, scale)
: 0; : 0;
FrameThickness = EnabledParts.HasFlag(DrawnWindowDecorationParts.Border) FrameThickness = EnabledParts.HasFlag(DrawnWindowDecorationParts.Border)
? (FrameThicknessOverride ?? DefaultFrameThickness) ? LayoutHelper.RoundLayoutThickness(FrameThicknessOverride ?? DefaultFrameThickness, scale)
: default; : default;
ShadowThickness = EnabledParts.HasFlag(DrawnWindowDecorationParts.Shadow) ShadowThickness = EnabledParts.HasFlag(DrawnWindowDecorationParts.Shadow)
? (ShadowThicknessOverride ?? DefaultShadowThickness) ? LayoutHelper.RoundLayoutThickness(ShadowThicknessOverride ?? DefaultShadowThickness, scale)
: default; : default;
EffectiveGeometryChanged?.Invoke(); EffectiveGeometryChanged?.Invoke();

5
src/Avalonia.Controls/PresentationSource/PresentationSource.RenderRoot.cs

@ -1,3 +1,4 @@
using Avalonia.Input;
using System; using System;
using Avalonia.Layout; using Avalonia.Layout;
using Avalonia.Rendering; using Avalonia.Rendering;
@ -7,7 +8,6 @@ namespace Avalonia.Controls;
internal partial class PresentationSource internal partial class PresentationSource
{ {
private readonly Func<Size> _clientSizeProvider;
public CompositingRenderer Renderer { get; } public CompositingRenderer Renderer { get; }
IRenderer IPresentationSource.Renderer => Renderer; IRenderer IPresentationSource.Renderer => Renderer;
Visual IPresentationSource.RootVisual => RootVisual; Visual IPresentationSource.RootVisual => RootVisual;
@ -16,8 +16,7 @@ internal partial class PresentationSource
public IHitTester? HitTesterOverride { get; set; } public IHitTester? HitTesterOverride { get; set; }
public double RenderScaling { get; private set; } = 1.0; public double RenderScaling { get; private set; } = 1.0;
public Size ClientSize => PlatformImpl?.ClientSize ?? default;
public Size ClientSize => _clientSizeProvider();
public void SceneInvalidated(object? sender, SceneInvalidatedEventArgs sceneInvalidatedEventArgs) public void SceneInvalidated(object? sender, SceneInvalidatedEventArgs sceneInvalidatedEventArgs)
{ {

4
src/Avalonia.Controls/PresentationSource/PresentationSource.cs

@ -23,10 +23,8 @@ internal partial class PresentationSource : IPresentationSource, IInputRoot, IDi
public PresentationSource(InputElement rootVisual, InputElement defaultFocusVisual, public PresentationSource(InputElement rootVisual, InputElement defaultFocusVisual,
ITopLevelImpl platformImpl, ITopLevelImpl platformImpl,
IAvaloniaDependencyResolver dependencyResolver, Func<Size> clientSizeProvider) IAvaloniaDependencyResolver dependencyResolver)
{ {
_clientSizeProvider = clientSizeProvider;
PlatformImpl = platformImpl; PlatformImpl = platformImpl;

2
src/Avalonia.Controls/TopLevel.cs

@ -212,7 +212,7 @@ namespace Avalonia.Controls
LogicalChildren.Add(hostVisual); LogicalChildren.Add(hostVisual);
_source = new PresentationSource(hostVisual, this, _source = new PresentationSource(hostVisual, this,
impl, dependencyResolver, () => ClientSize); impl, dependencyResolver);
_source.Renderer.SceneInvalidated += SceneInvalidated; _source.Renderer.SceneInvalidated += SceneInvalidated;
_scaling = LayoutHelper.ValidateScaling(impl.RenderScaling); _scaling = LayoutHelper.ValidateScaling(impl.RenderScaling);

87
src/Avalonia.Controls/TopLevelHost.cs

@ -3,6 +3,7 @@ using System.Collections.Generic;
using Avalonia.Automation.Peers; using Avalonia.Automation.Peers;
using Avalonia.Controls.Chrome; using Avalonia.Controls.Chrome;
using Avalonia.Input; using Avalonia.Input;
using Avalonia.Layout;
using Avalonia.LogicalTree; using Avalonia.LogicalTree;
using Avalonia.Reactive; using Avalonia.Reactive;
@ -14,6 +15,8 @@ namespace Avalonia.Controls;
/// </summary> /// </summary>
internal partial class TopLevelHost : Control internal partial class TopLevelHost : Control
{ {
private Thickness _decorationInset;
static TopLevelHost() static TopLevelHost()
{ {
KeyboardNavigation.TabNavigationProperty.OverrideDefaultValue<TopLevelHost>(KeyboardNavigationMode.Cycle); KeyboardNavigation.TabNavigationProperty.OverrideDefaultValue<TopLevelHost>(KeyboardNavigationMode.Cycle);
@ -25,5 +28,89 @@ internal partial class TopLevelHost : Control
VisualChildren.Add(tl); VisualChildren.Add(tl);
} }
/// <summary>
/// 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.
/// </summary>
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; protected override bool BypassFlowDirectionPolicies => true;
} }

99
src/Avalonia.Controls/Window.cs

@ -98,6 +98,7 @@ namespace Avalonia.Controls
private Thickness _offScreenMargin; private Thickness _offScreenMargin;
private bool _canHandleResized = false; private bool _canHandleResized = false;
private Size _arrangeBounds; private Size _arrangeBounds;
private bool _isForcedDecorationMode;
/// <summary> /// <summary>
/// Defines the <see cref="SizeToContent"/> property. /// Defines the <see cref="SizeToContent"/> property.
@ -249,7 +250,11 @@ namespace Avalonia.Controls
impl.WindowStateChanged = HandleWindowStateChanged; impl.WindowStateChanged = HandleWindowStateChanged;
_maxPlatformClientSize = PlatformImpl?.MaxAutoSizeHint ?? default(Size); _maxPlatformClientSize = PlatformImpl?.MaxAutoSizeHint ?? default(Size);
impl.ExtendClientAreaToDecorationsChanged = ExtendClientAreaToDecorationsChanged; 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(TitleProperty, title => PlatformImpl!.SetTitle(title));
CreatePlatformImplBinding(IconProperty, SetEffectiveIcon); CreatePlatformImplBinding(IconProperty, SetEffectiveIcon);
@ -668,7 +673,7 @@ namespace Avalonia.Controls
UpdateDrawnDecorationParts(); UpdateDrawnDecorationParts();
} }
protected virtual void ExtendClientAreaToDecorationsChanged(bool isExtended) private void ExtendClientAreaToDecorationsChanged(bool isExtended)
{ {
IsExtendedIntoWindowDecorations = isExtended; IsExtendedIntoWindowDecorations = isExtended;
OffScreenMargin = PlatformImpl?.OffScreenMargin ?? default; OffScreenMargin = PlatformImpl?.OffScreenMargin ?? default;
@ -679,6 +684,10 @@ namespace Avalonia.Controls
private void UpdateDrawnDecorations() private void UpdateDrawnDecorations()
{ {
var parts = ComputeDecorationParts(); var parts = ComputeDecorationParts();
// Detect forced mode: platform needs managed decorations but app hasn't opted in
_isForcedDecorationMode = parts != null && !IsExtendedIntoWindowDecorations;
TopLevelHost.UpdateDrawnDecorations(parts, WindowState); TopLevelHost.UpdateDrawnDecorations(parts, WindowState);
if (parts != null) if (parts != null)
@ -687,6 +696,8 @@ namespace Avalonia.Controls
var decorations = TopLevelHost.Decorations; var decorations = TopLevelHost.Decorations;
if (decorations != null) if (decorations != null)
{ {
decorations.RenderScaling = RenderScaling;
var hint = ExtendClientAreaTitleBarHeightHint; var hint = ExtendClientAreaTitleBarHeightHint;
if (hint >= 0) if (hint >= 0)
decorations.TitleBarHeightOverride = hint; decorations.TitleBarHeightOverride = hint;
@ -696,6 +707,13 @@ namespace Avalonia.Controls
UpdateDrawnDecorationMargins(); UpdateDrawnDecorationMargins();
} }
private void OnScalingChangedUpdateDecorations(object? sender, EventArgs e)
{
var decorations = TopLevelHost.Decorations;
if (decorations != null)
decorations.RenderScaling = RenderScaling;
}
/// <summary> /// <summary>
/// Updates decoration parts based on current window state without /// Updates decoration parts based on current window state without
/// re-creating the decorations instance. /// re-creating the decorations instance.
@ -753,7 +771,9 @@ namespace Avalonia.Controls
var decorations = TopLevelHost.Decorations; var decorations = TopLevelHost.Decorations;
if (decorations == null) if (decorations == null)
{ {
// Only use platform margins if drawn decorations are not active
WindowDecorationMargin = PlatformImpl?.ExtendedMargins ?? default; WindowDecorationMargin = PlatformImpl?.ExtendedMargins ?? default;
TopLevelHost.DecorationInset = default;
return; return;
} }
@ -764,11 +784,25 @@ namespace Avalonia.Controls
? decorations.FrameThickness : default; ? decorations.FrameThickness : default;
var shadow = parts.HasFlag(Chrome.DrawnWindowDecorationParts.Shadow) var shadow = parts.HasFlag(Chrome.DrawnWindowDecorationParts.Shadow)
? decorations.ShadowThickness : default; ? decorations.ShadowThickness : default;
WindowDecorationMargin = new Thickness( var margin = new Thickness(
frame.Left + shadow.Left, frame.Left + shadow.Left,
titleBarHeight + frame.Top + shadow.Top, titleBarHeight + frame.Top + shadow.Top,
frame.Right + shadow.Right, frame.Right + shadow.Right,
frame.Bottom + shadow.Bottom); 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() private void OnTitleBarHeightHintChanged()
@ -933,6 +967,15 @@ namespace Avalonia.Controls
// Enable drawn decorations before layout so margins are computed // Enable drawn decorations before layout so margins are computed
UpdateDrawnDecorations(); 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; _shown = true;
IsVisible = true; IsVisible = true;
@ -978,10 +1021,18 @@ namespace Avalonia.Controls
DesktopScalingOverride = null; 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 // 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. // we do not want PlatformImpl?.Resize to trigger HandleResized yet because it will set Width and Height.
// So perform some important actions from HandleResized // 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);
}
/// <summary> /// <summary>
/// Shows the window as a dialog. /// Shows the window as a dialog.
/// </summary> /// </summary>
@ -1272,6 +1339,14 @@ namespace Avalonia.Controls
{ {
var sizeToContent = SizeToContent; var sizeToContent = SizeToContent;
var clientSize = ClientSize; 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 maxAutoSize = PlatformImpl?.MaxAutoSizeHint ?? Size.Infinity;
var useAutoWidth = sizeToContent.HasAllFlags(SizeToContent.Width); var useAutoWidth = sizeToContent.HasAllFlags(SizeToContent.Width);
var useAutoHeight = sizeToContent.HasAllFlags(SizeToContent.Height); var useAutoHeight = sizeToContent.HasAllFlags(SizeToContent.Height);
@ -1332,7 +1407,9 @@ namespace Avalonia.Controls
{ {
_arrangeBounds = size; _arrangeBounds = size;
if (_canHandleResized) if (_canHandleResized)
PlatformImpl?.Resize(size, WindowResizeReason.Layout); {
ResizePlatformImpl(size, WindowResizeReason.Layout);
}
return ClientSize; return ClientSize;
} }
@ -1350,6 +1427,16 @@ namespace Avalonia.Controls
/// <inheritdoc/> /// <inheritdoc/>
internal override void HandleResized(Size clientSize, WindowResizeReason reason) 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))) if (_canHandleResized && (ClientSize != clientSize || double.IsNaN(Width) || double.IsNaN(Height)))
{ {
var sizeToContent = SizeToContent; var sizeToContent = SizeToContent;

2
src/Avalonia.Controls/WindowBase.cs

@ -304,7 +304,7 @@ namespace Avalonia.Controls
{ {
var constraint = ArrangeSetBounds(finalRect.Size); var constraint = ArrangeSetBounds(finalRect.Size);
var arrangeSize = ArrangeOverride(constraint); var arrangeSize = ArrangeOverride(constraint);
Bounds = new Rect(arrangeSize); Bounds = new Rect(finalRect.Position, arrangeSize);
} }
/// <summary> /// <summary>

31
src/Avalonia.X11/X11Platform.cs

@ -482,13 +482,32 @@ namespace Avalonia
, Message = "Experimental, used mostly for testing" , Message = "Experimental, used mostly for testing"
#endif #endif
)] )]
public bool? EnableDrawnDecorations public bool? EnableDrawnDecorations { get; set; }
{
get => EnableDrawnDecorationsInternal; internal bool EnableDrawnDecorationsInternal =>
set => EnableDrawnDecorationsInternal = value; #pragma warning disable AVALONIA_X11_CSD
} EnableDrawnDecorations == true || ForceDrawnDecorationsInternal;
#pragma warning restore AVALONIA_X11_CSD
/// <summary>
/// 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.
/// </summary>
[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
/// <summary> /// <summary>
/// If Avalonia is in control of a run loop, we propagate exceptions by stopping the run loop frame /// If Avalonia is in control of a run loop, we propagate exceptions by stopping the run loop frame

19
src/Avalonia.X11/X11Window.cs

@ -926,7 +926,7 @@ namespace Avalonia.X11
mouse.Position = mouse.Position / RenderScaling; mouse.Position = mouse.Position / RenderScaling;
// Chrome hit-test for drawn decorations // Chrome hit-test for drawn decorations
if (_extendClientAreaToDecorations if (UseManagedDecorations
&& mouse.Type == RawPointerEventType.LeftButtonDown && mouse.Type == RawPointerEventType.LeftButtonDown
&& _inputRoot is { } inputRoot) && _inputRoot is { } inputRoot)
{ {
@ -1187,8 +1187,8 @@ namespace Avalonia.X11
private void UpdateEffectiveSystemDecorations() private void UpdateEffectiveSystemDecorations()
{ {
// When extending client area, always hide WM decorations (we draw our own) // When extending client area or forcing drawn decorations, always hide WM decorations (we draw our own)
var effective = _extendClientAreaToDecorations var effective = UseManagedDecorations
? WindowDecorations.None ? WindowDecorations.None
: (_requestedWindowDecorations == WindowDecorations.Full : (_requestedWindowDecorations == WindowDecorations.Full
? 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) public void SetExtendClientAreaToDecorationsHint(bool extendIntoClientAreaHint)
{ {
if (_platform.Options.EnableDrawnDecorationsInternal != true) if (!_platform.Options.EnableDrawnDecorationsInternal)
return; return;
if (_extendClientAreaToDecorations == extendIntoClientAreaHint) if (_extendingClientAreaToDecorations == extendIntoClientAreaHint)
return; return;
_extendClientAreaToDecorations = extendIntoClientAreaHint; _extendingClientAreaToDecorations = extendIntoClientAreaHint;
UpdateEffectiveSystemDecorations(); UpdateEffectiveSystemDecorations();
IsClientAreaExtendedToDecorations = extendIntoClientAreaHint; IsClientAreaExtendedToDecorations = extendIntoClientAreaHint;
@ -1606,10 +1607,10 @@ namespace Avalonia.X11
public AcrylicPlatformCompensationLevels AcrylicCompensationLevels { get; } = new AcrylicPlatformCompensationLevels(1, 0.8, 0.8); public AcrylicPlatformCompensationLevels AcrylicCompensationLevels { get; } = new AcrylicPlatformCompensationLevels(1, 0.8, 0.8);
public bool NeedsManagedDecorations => _extendClientAreaToDecorations; public bool NeedsManagedDecorations => UseManagedDecorations;
public PlatformRequestedDrawnDecoration RequestedDrawnDecorations => public PlatformRequestedDrawnDecoration RequestedDrawnDecorations =>
_extendClientAreaToDecorations UseManagedDecorations
? PlatformRequestedDrawnDecoration.Border ? PlatformRequestedDrawnDecoration.Border
| PlatformRequestedDrawnDecoration.ResizeGrips | PlatformRequestedDrawnDecoration.ResizeGrips
| PlatformRequestedDrawnDecoration.TitleBar | PlatformRequestedDrawnDecoration.TitleBar

1
src/Windows/Avalonia.Win32/WindowImpl.cs

@ -1188,6 +1188,7 @@ namespace Avalonia.Win32
{ {
if (!_shown) if (!_shown)
{ {
ExtendClientAreaToDecorationsChanged?.Invoke(_isClientAreaExtended);
return; return;
} }

231
tests/Avalonia.Controls.UnitTests/WindowTests.cs

@ -2,6 +2,7 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Reactive.Linq; using System.Reactive.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia.Controls.Platform;
using Avalonia.Media; using Avalonia.Media;
using Avalonia.Platform; using Avalonia.Platform;
using Avalonia.Threading; using Avalonia.Threading;
@ -1164,6 +1165,236 @@ namespace Avalonia.Controls.UnitTests
} }
} }
public class ForcedDecorationSizingTests : ScopedTestBase
{
/// <summary>
/// Creates a mock IWindowImpl that simulates forced CSD mode:
/// NeedsManagedDecorations = true, RequestedDrawnDecorations includes TitleBar + Border,
/// but IsClientAreaExtendedToDecorations = false.
/// </summary>
private static Mock<IWindowImpl> 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<IWindowImpl> CreateImpl() private static Mock<IWindowImpl> CreateImpl()
{ {
var screen1 = new MockScreen(1.75, new PixelRect(new PixelSize(1920, 1080)), new PixelRect(new PixelSize(1920, 966)), true); var screen1 = new MockScreen(1.75, new PixelRect(new PixelSize(1920, 1080)), new PixelRect(new PixelSize(1920, 966)), true);

Loading…
Cancel
Save