fix(Dialog,Window): stabilize macOS modal window chrome

This commit is contained in:
chinboy
2026-07-25 12:27:57 +08:00
parent 875d14244a
commit ef094753dd
8 changed files with 331 additions and 3 deletions
@@ -24,6 +24,7 @@ internal sealed class WindowDialogPresenter : IDialogPresenter
private Task? _showTask;
private Task? _closeTask;
private Task? _disposeTask;
private MacOsDisabledOwnerInputActivationScope? _macOsDisabledOwnerInputActivationScope;
private DialogSizeConstraints _normalSizeConstraints;
private Size? _normalSurfaceSize;
private Size _windowChromeSize;
@@ -150,7 +151,17 @@ internal sealed class WindowDialogPresenter : IDialogPresenter
_owner is Window ownerWindow &&
RuntimePlatform.Features.SupportsWindowModalDialog)
{
_modalTask = HostWindow.ShowDialog(ownerWindow);
_macOsDisabledOwnerInputActivationScope =
CreateMacOsDisabledOwnerInputActivationScope(ownerWindow);
try
{
_modalTask = HostWindow.ShowDialog(ownerWindow);
}
catch
{
ReleaseMacOsDisabledOwnerInputActivationScope();
throw;
}
}
else
{
@@ -161,6 +172,22 @@ internal sealed class WindowDialogPresenter : IDialogPresenter
openingCancellationToken.ThrowIfCancellationRequested();
}
private MacOsDisabledOwnerInputActivationScope? CreateMacOsDisabledOwnerInputActivationScope(Window ownerWindow)
{
if (!OperatingSystem.IsMacOS())
{
return null;
}
return MacOsDisabledOwnerInputActivationScope.TryAttach(ownerWindow.PlatformImpl, HostWindow);
}
private void ReleaseMacOsDisabledOwnerInputActivationScope()
{
_macOsDisabledOwnerInputActivationScope?.Dispose();
_macOsDisabledOwnerInputActivationScope = null;
}
private void ApplyStartupPlacement(Size windowSize)
{
var ownerBounds = GetOwnerBounds(_owner);
@@ -611,6 +638,7 @@ internal sealed class WindowDialogPresenter : IDialogPresenter
private void HandleWindowClosed(object? sender, EventArgs e)
{
ReleaseMacOsDisabledOwnerInputActivationScope();
_closedSource.TrySetResult();
}
@@ -782,4 +810,71 @@ internal sealed class WindowDialogPresenter : IDialogPresenter
HostWindow.IsCustomResizerVisible &&
OperatingSystem.IsLinux() &&
AbstractLinuxWindowChromeManager.IsWayland(HostWindow);
private sealed class MacOsDisabledOwnerInputActivationScope : IDisposable
{
private readonly Avalonia.Platform.IWindowImpl _ownerPlatformImpl;
private readonly DialogWindow _hostWindow;
private readonly Action? _originalCallback;
private readonly Action _replacementCallback;
private bool _isDisposed;
private MacOsDisabledOwnerInputActivationScope(
Avalonia.Platform.IWindowImpl ownerPlatformImpl,
DialogWindow hostWindow)
{
_ownerPlatformImpl = ownerPlatformImpl;
_hostWindow = hostWindow;
_originalCallback = ownerPlatformImpl.GotInputWhenDisabled;
_replacementCallback = HandleGotInputWhenDisabled;
_ownerPlatformImpl.GotInputWhenDisabled = _replacementCallback;
}
public static MacOsDisabledOwnerInputActivationScope? TryAttach(
Avalonia.Platform.IWindowImpl? ownerPlatformImpl,
DialogWindow hostWindow)
{
return ownerPlatformImpl is null
? null
: new MacOsDisabledOwnerInputActivationScope(ownerPlatformImpl, hostWindow);
}
public void Dispose()
{
if (_isDisposed)
{
return;
}
_isDisposed = true;
if (_ownerPlatformImpl.GotInputWhenDisabled == _replacementCallback)
{
_ownerPlatformImpl.GotInputWhenDisabled =
ResolveRestoredCallback(_originalCallback);
}
}
private void HandleGotInputWhenDisabled()
{
if (_hostWindow.IsVisible && _hostWindow.IsActive)
{
return;
}
var originalCallback = _originalCallback;
originalCallback?.Invoke();
}
private static Action? ResolveRestoredCallback(Action? callback)
{
while (callback?.Target is MacOsDisabledOwnerInputActivationScope scope &&
callback == scope._replacementCallback &&
scope._isDisposed)
{
callback = scope._originalCallback;
}
return callback;
}
}
}
@@ -0,0 +1,81 @@
using AtomUI.Media;
using AtomUI.Native;
using Avalonia;
using Avalonia.Media;
namespace AtomUI.Desktop.Controls;
using AvaloniaWindow = Avalonia.Controls.Window;
internal sealed class MacOSWindowChromeManager : IWindowChromeManager
{
private const double DisabledManagedResizeGripScale = 0.0;
private readonly Window _window;
private bool _initialShowStatePrepared;
private MacOSWindowChromeManager(Window window)
{
_window = window;
}
internal static MacOSWindowChromeManager Attach(Window window)
{
return new MacOSWindowChromeManager(window);
}
public bool UsesCustomResizer => false;
public Action? PrepareInitialShowState()
{
if (_initialShowStatePrepared || _window.IsVisible)
{
return null;
}
_initialShowStatePrepared = true;
_window.PreparePlatformChromeInitialShowLayout();
UpdateFrameGeometry();
return null;
}
public void HandleFrameShadowChanged(BoxShadows frameShadow)
{
_window.FrameShadowThickness = frameShadow.Thickness();
}
public void ConfigureTitleBarHeightHint(double height)
{
_window.SetCurrentValue(AvaloniaWindow.ExtendClientAreaTitleBarHeightHintProperty, height);
}
public void HandlePropertyChanged(AvaloniaProperty property)
{
if (property == AvaloniaWindow.WindowStateProperty ||
property == AvaloniaWindow.CanResizeProperty ||
property == AvaloniaWindow.WindowDecorationMarginProperty ||
property == Window.FrameShadowThicknessProperty ||
property == Window.IsCsdEnabledProperty)
{
UpdateFrameGeometry();
}
}
public void UpdateFrameGeometry()
{
if (_window.IsCsdEnabled)
{
if (OperatingSystem.IsMacOS())
{
_window.SetMacOsResizeIndicatorVisible(true);
}
return;
}
if (OperatingSystem.IsMacOS())
{
_window.SetMacOsResizeIndicatorVisible(false);
}
_window.TryTakeOverManagedResizeGrip(DisabledManagedResizeGripScale, out _);
}
}
@@ -32,6 +32,11 @@ internal static class WindowChromeManager
return WindowsWindowChromeManager.Attach(window);
}
if (OperatingSystem.IsMacOS())
{
return MacOSWindowChromeManager.Attach(window);
}
return null;
}
}
@@ -144,6 +144,7 @@ internal static class WindowUtilsInterop
public static readonly IntPtr FrameSelector = sel_registerName("frame");
public static readonly IntPtr SuperviewSelector = sel_registerName("superview");
public static readonly IntPtr SetFrameOriginSelector = sel_registerName("setFrameOrigin:");
public static readonly IntPtr SetShowsResizeIndicatorSelector = sel_registerName("setShowsResizeIndicator:");
public static readonly IntPtr UpdateTrackingAreasSelector = sel_registerName("updateTrackingAreas");
public static readonly IntPtr SetNeedsDisplaySelector = sel_registerName("setNeedsDisplay:");
@@ -216,4 +217,4 @@ internal static class WindowUtilsInterop
}
objc_msgSend_void_bool(view, SetNeedsDisplaySelector, flag);
}
}
}
+17 -1
View File
@@ -35,6 +35,22 @@ internal static class WindowUtilsMacOS
var handle = window.TryGetPlatformHandle();
return handle?.Handle ?? IntPtr.Zero;
}
public static void SetResizeIndicatorVisible(Window window, bool isVisible)
{
if (!OperatingSystem.IsMacOS())
{
return;
}
var ns = GetNSWindow(window);
if (ns == IntPtr.Zero)
{
return;
}
WindowUtilsInterop.objc_msgSend_void_bool(ns, WindowUtilsInterop.SetShowsResizeIndicatorSelector, isVisible);
}
private static IntPtr GetButton(IntPtr ns, WindowUtilsInterop.NSWindowButton which)
{
@@ -339,4 +355,4 @@ internal static class WindowUtilsMacOS
}
return sh - (titleBarHeight + cf.Height) / 2.0;
}
}
}
+11
View File
@@ -79,6 +79,17 @@ internal static class WindowExtensions
WindowUtilsWindows.SetWindowFrameDarkModeWindows(handle.Handle, isDarkMode);
}
[SupportedOSPlatform("macos")]
public static void SetMacOsResizeIndicatorVisible(this Window window, bool isVisible)
{
if (!OperatingSystem.IsMacOS())
{
return;
}
WindowUtilsMacOS.SetResizeIndicatorVisible(window, isVisible);
}
/// <summary>
/// 窗体输入区域 (X11 SHAPE input region) 控制扩展。
@@ -1178,6 +1178,31 @@ public class WindowDialogPresenterTests
}
}
[Fact]
public void MacOs_Modal_Disabled_Owner_Input_Suppresses_Only_Redundant_Dialog_Activation()
{
var source = File.ReadAllText(GetRepoFile(
"src/AtomUI.Desktop.Controls/Dialog/WindowHost/WindowDialogPresenter.cs"));
var showDialogBranchStart = source.IndexOf(
"HostWindow.ShowDialog(ownerWindow)",
StringComparison.Ordinal);
var macOsScopeStart = source.IndexOf(
"CreateMacOsDisabledOwnerInputActivationScope(ownerWindow)",
StringComparison.Ordinal);
showDialogBranchStart.ShouldBeGreaterThanOrEqualTo(0);
macOsScopeStart.ShouldBeGreaterThanOrEqualTo(0);
macOsScopeStart.ShouldBeLessThan(showDialogBranchStart);
source.ShouldContain("OperatingSystem.IsMacOS()");
source.ShouldContain("GotInputWhenDisabled");
source.ShouldContain("HostWindow.IsVisible");
source.ShouldContain("HostWindow.IsActive");
source.ShouldContain("return;");
source.ShouldContain("originalCallback?.Invoke();");
source.ShouldContain("ReleaseMacOsDisabledOwnerInputActivationScope");
}
[Fact]
public void Presenter_Uses_The_Shared_Surface_Inside_One_Native_Window()
{
@@ -1411,6 +1436,23 @@ public class WindowDialogPresenterTests
}
}
private static string GetRepoFile(string relativePath)
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null)
{
var candidate = Path.Combine(directory.FullName, relativePath);
if (File.Exists(candidate))
{
return candidate;
}
directory = directory.Parent;
}
throw new FileNotFoundException($"Could not find repository file: {relativePath}");
}
private static void WaitWithDispatcherPump(Task task)
{
var timeoutAt = DateTimeOffset.UtcNow + TimeSpan.FromSeconds(5);
@@ -4,8 +4,10 @@ using System.Linq;
using System.Runtime.Versioning;
using System.Xml.Linq;
using AtomUI.Controls;
using AtomUI.Media;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Media;
using Avalonia.Styling;
using Avalonia.VisualTree;
using Shouldly;
@@ -680,6 +682,81 @@ public class WindowResizeArtifactTests
relayoutBlock.ShouldContain("Avalonia.Threading.DispatcherPriority.Loaded");
}
[Fact]
public void MacOs_NonCsd_Window_Uses_Native_Resize_Region_Without_Internal_Cursor_Layer()
{
var document = XDocument.Load(GetRepoFile("src/AtomUI.Desktop.Controls/Window/Themes/WindowTheme.axaml"));
var managerSource = File.ReadAllText(GetRepoFile(
"src/AtomUI.Desktop.Controls/Window/Chrome/WindowChromeManager.cs"));
var macOsManagerSource = File.ReadAllText(GetRepoFile(
"src/AtomUI.Desktop.Controls/Window/Chrome/MacOSWindowChromeManager.cs"));
XNamespace av = "https://github.com/avaloniaui";
XNamespace atom = "https://atomui.net";
var macOsTemplateStyle = document.Descendants(av + "Style")
.Single(element =>
(string?)element.Attribute("Selector") ==
"^[OsType=macOS][IsCsdEnabled=False]" &&
element.Descendants(av + "ControlTemplate").Any());
macOsTemplateStyle.Descendants(atom + "WindowResizer").ShouldBeEmpty();
managerSource.ShouldContain("MacOSWindowChromeManager.Attach(window)");
macOsManagerSource.ShouldContain("public bool UsesCustomResizer => false;");
macOsManagerSource.ShouldContain("Window.TryTakeOverManagedResizeGrip(");
macOsManagerSource.ShouldNotContain("Window.ConfigureManagedResizeGrip(");
macOsManagerSource.ShouldContain("_window.SetMacOsResizeIndicatorVisible(false);");
}
[Fact]
public void MacOs_Resize_Cursor_Manager_Does_Not_Replace_Other_Platform_Managers()
{
var managerSource = File.ReadAllText(GetRepoFile(
"src/AtomUI.Desktop.Controls/Window/Chrome/WindowChromeManager.cs"));
var linuxBranchStart = managerSource.IndexOf(
"if (OperatingSystem.IsLinux())",
StringComparison.Ordinal);
var windowsBranchStart = managerSource.IndexOf(
"if (OperatingSystem.IsWindows())",
linuxBranchStart + 1,
StringComparison.Ordinal);
var macOsBranchStart = managerSource.IndexOf(
"if (OperatingSystem.IsMacOS())",
windowsBranchStart + 1,
StringComparison.Ordinal);
linuxBranchStart.ShouldBeGreaterThanOrEqualTo(0);
windowsBranchStart.ShouldBeGreaterThan(linuxBranchStart);
macOsBranchStart.ShouldBeGreaterThan(windowsBranchStart);
managerSource[linuxBranchStart..windowsBranchStart].ShouldContain(
"AbstractLinuxWindowChromeManager.Attach(window)");
managerSource[windowsBranchStart..macOsBranchStart].ShouldContain(
"WindowsWindowChromeManager.Attach(window)");
managerSource[macOsBranchStart..].ShouldContain("MacOSWindowChromeManager.Attach(window)");
}
[Fact]
public void MacOs_Window_Chrome_Manager_Preserves_Default_Frame_Geometry_Updates()
{
AvaloniaTestApp.EnsureInitialized();
var window = new AtomUI.Desktop.Controls.Window();
var manager = MacOSWindowChromeManager.Attach(window);
var frameShadow = new BoxShadows(new BoxShadow
{
OffsetX = 3,
OffsetY = 4,
Blur = 10,
Spread = 2
});
manager.HandleFrameShadowChanged(frameShadow);
manager.ConfigureTitleBarHeightHint(42);
manager.UsesCustomResizer.ShouldBeFalse();
window.FrameShadowThickness.ShouldBe(frameShadow.Thickness());
window.ExtendClientAreaTitleBarHeightHint.ShouldBe(42);
}
[Fact]
public void Linux_NonCsd_Window_Hides_Only_The_AtomUI_TitleBar_And_Keeps_Native_Decorations_Disabled()
{