mirror of
https://github.com/AtomUI/AtomUI.git
synced 2026-10-29 07:16:33 +08:00
fix(Dialog,Window): stabilize macOS modal window chrome
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user