diff --git a/src/AtomUI.Desktop.Controls/Dialog/WindowHost/WindowDialogPresenter.cs b/src/AtomUI.Desktop.Controls/Dialog/WindowHost/WindowDialogPresenter.cs index ae7df11c7..e71a238a4 100644 --- a/src/AtomUI.Desktop.Controls/Dialog/WindowHost/WindowDialogPresenter.cs +++ b/src/AtomUI.Desktop.Controls/Dialog/WindowHost/WindowDialogPresenter.cs @@ -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; + } + } } diff --git a/src/AtomUI.Desktop.Controls/Window/Chrome/MacOSWindowChromeManager.cs b/src/AtomUI.Desktop.Controls/Window/Chrome/MacOSWindowChromeManager.cs new file mode 100644 index 000000000..ebe5be882 --- /dev/null +++ b/src/AtomUI.Desktop.Controls/Window/Chrome/MacOSWindowChromeManager.cs @@ -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 _); + } +} diff --git a/src/AtomUI.Desktop.Controls/Window/Chrome/WindowChromeManager.cs b/src/AtomUI.Desktop.Controls/Window/Chrome/WindowChromeManager.cs index fdb57122e..e6510cd4b 100644 --- a/src/AtomUI.Desktop.Controls/Window/Chrome/WindowChromeManager.cs +++ b/src/AtomUI.Desktop.Controls/Window/Chrome/WindowChromeManager.cs @@ -32,6 +32,11 @@ internal static class WindowChromeManager return WindowsWindowChromeManager.Attach(window); } + if (OperatingSystem.IsMacOS()) + { + return MacOSWindowChromeManager.Attach(window); + } + return null; } } diff --git a/src/AtomUI.Native/MacOS/WindowUtils.Interop.cs b/src/AtomUI.Native/MacOS/WindowUtils.Interop.cs index 059e73156..b6bc17421 100644 --- a/src/AtomUI.Native/MacOS/WindowUtils.Interop.cs +++ b/src/AtomUI.Native/MacOS/WindowUtils.Interop.cs @@ -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); } -} \ No newline at end of file +} diff --git a/src/AtomUI.Native/MacOS/WindowUtils.MacOS.cs b/src/AtomUI.Native/MacOS/WindowUtils.MacOS.cs index 97cf2ebe9..d2f549a4b 100644 --- a/src/AtomUI.Native/MacOS/WindowUtils.MacOS.cs +++ b/src/AtomUI.Native/MacOS/WindowUtils.MacOS.cs @@ -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; } -} \ No newline at end of file +} diff --git a/src/AtomUI.Native/WindowExtensions.cs b/src/AtomUI.Native/WindowExtensions.cs index 041c93d6c..752147317 100644 --- a/src/AtomUI.Native/WindowExtensions.cs +++ b/src/AtomUI.Native/WindowExtensions.cs @@ -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); + } /// /// 窗体输入区域 (X11 SHAPE input region) 控制扩展。 diff --git a/tests/AtomUI.Desktop.Controls.Tests/Dialog/WindowDialogPresenterTests.cs b/tests/AtomUI.Desktop.Controls.Tests/Dialog/WindowDialogPresenterTests.cs index 22a1f6735..607852139 100644 --- a/tests/AtomUI.Desktop.Controls.Tests/Dialog/WindowDialogPresenterTests.cs +++ b/tests/AtomUI.Desktop.Controls.Tests/Dialog/WindowDialogPresenterTests.cs @@ -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); diff --git a/tests/AtomUI.Desktop.Controls.Tests/Window/WindowResizeArtifactTests.cs b/tests/AtomUI.Desktop.Controls.Tests/Window/WindowResizeArtifactTests.cs index a3c75f144..0991fc189 100644 --- a/tests/AtomUI.Desktop.Controls.Tests/Window/WindowResizeArtifactTests.cs +++ b/tests/AtomUI.Desktop.Controls.Tests/Window/WindowResizeArtifactTests.cs @@ -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() {