diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 50ab67e0..8578daa2 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -158,13 +158,14 @@ wheel flips). In click mode the focused window gets the key (`DonutMenu.HandleKe
drag the donut's window has no keyboard focus, so `RadialController` turns on `TriggerService.WatchFlipKey` while
the drag donut shows files and flips on `FlipKeyPressed` / `FlipWheelTurned`: the global hook only listens (it
can't hold an event back; Explorer and Finder ignore Tab during a drag) and reports one flip per key press. On
-macOS the drag donut takes the keyboard once files are on it (`RadialController.TakeKeyboard`), and the key reaches
-its window as in click mode: while another app holds Secure Input (Terminal with Secure Keyboard Entry can keep it
-from the background) macOS gives no keys to a hook, only to the app in front. The hook's copy of a press is ignored
-while the window has the keyboard (`HookFlipsKey`, one flip per press, following `IsActive`); the keyboard goes back
-to the app that had it once a conversion starts or the donut closes (`IPlatformServices.RememberFrontApp` /
-`ReturnFocus`), unless the user clicked elsewhere meanwhile, and stays with Filee when the drop opens a window of its
-own (a tool, the merge order, the save dialog). Not on Windows, where taking the keyboard during a drag breaks Explorer's.
+macOS the drag donut takes key window status once files are on it (`RadialController.TakeKeyboard` / `IPlatformServices.TakeKeyWindow`),
+and the key reaches its window without whole-application activation, keeping macOS Show Desktop (Desktop Reveal) and Exposé uninterrupted:
+while another app holds Secure Input (Terminal with Secure Keyboard Entry can keep it from the background) macOS gives no keys to a hook,
+only to the key window and app in front. The hook's copy of a press is ignored while the window has the keyboard (`HookFlipsKey`, one flip
+per press, following `IsActive`); key window status is explicitly released once a conversion starts or the donut closes
+(`IPlatformServices.ReleaseKeyWindow` / `ReturnFocus`), returning typing to the previous app (unless the user clicked elsewhere meanwhile) while the progress ring finishes, and stays
+with Filee when the drop opens a window of its own (a tool, the merge order, the save dialog). Not on Windows, where taking the keyboard
+during a drag breaks Explorer's.
The editor's Front / Back switch (`ToolbarPageViewModel.EditingBack`) edits either list with the same drag and drop.
A group is one slice that opens up to six non-group actions. Its members may be conversions or interactive tools
diff --git a/src/Filee.App/Controls/DonutMenu.Flip.cs b/src/Filee.App/Controls/DonutMenu.Flip.cs
index 1a95b530..02e4a000 100644
--- a/src/Filee.App/Controls/DonutMenu.Flip.cs
+++ b/src/Filee.App/Controls/DonutMenu.Flip.cs
@@ -6,8 +6,9 @@
// Click mode → the key reaches the focused window (HandleKey), the wheel the donut (OnPointerWheelChanged).
// Drag mode → the window has no keyboard focus during an OS drag: RadialController flips when the global hook
// sees the key or the wheel (TriggerService.FlipKeyPressed / FlipWheelTurned). On macOS the donut takes
-// the keyboard once files are dragged onto it (RadialController.TakeKeyboard), and the key then reaches
-// the window as in click mode: the hook may not see it while another app holds Secure Input.
+// key window status without whole-app activation once files are dragged onto it (RadialController.TakeKeyboard),
+// and the key then reaches the window as in click mode: the hook may not see it while another app holds
+// Secure Input. On drop or close, key window status is explicitly released (ReleaseKeyWindow / ReturnFocus).
//
// The owner swaps Items immediately when FaceSwapping is raised (RadialWindow → RadialViewModel). Old slices are
// retained for drawing only; hit-testing and drops always use the new face.
diff --git a/src/Filee.App/Services/RadialController.cs b/src/Filee.App/Services/RadialController.cs
index cf542dbb..d1a7e7d5 100644
--- a/src/Filee.App/Services/RadialController.cs
+++ b/src/Filee.App/Services/RadialController.cs
@@ -745,12 +745,14 @@ private void WatchFlipKey()
private bool _tookKeyboard;
///
- /// macOS: once files are dragged onto the donut, it takes the keyboard (Filee comes to the front), so the flip key
- /// reaches its window as a key press like any other. Watched from outside by the global hook it may not: while an
- /// app holds Secure Input (Terminal with Secure Keyboard Entry, which can keep it from the background) macOS
- /// gives no keys to anything watching, only to the app in front. The drag carries on; the keyboard goes back to the
- /// app that had it once the conversion starts or the donut closes (), and stays with
- /// Filee when the drop opens a window of its own: a tool, the merge order, the save dialog ().
+ /// macOS: once files are dragged onto the donut, it takes the keyboard without activating the entire application
+ /// (by making the non-activating panel the key window), so the flip key reaches its window as a key press while
+ /// leaving macOS "Show Desktop" (Desktop Reveal) mode uninterrupted. Watched from outside by the global hook it
+ /// may not reach: while an app holds Secure Input (Terminal with Secure Keyboard Entry, which can keep it from the
+ /// background) macOS gives no keys to a global hook, only to the key window and app in front. The drag carries on;
+ /// the keyboard goes back to the app that had it once the conversion starts or the donut closes (),
+ /// releasing key window status so typing returns to the previous app while the conversion ring finishes, and stays
+ /// with Filee when the drop opens a window of its own: a tool, the merge order, the save dialog ().
/// Not on Windows, where a window taking the keyboard during a drag breaks Explorer's drag.
///
private void TakeKeyboard(RadialWindow window)
@@ -759,7 +761,10 @@ private void TakeKeyboard(RadialWindow window)
return;
platform.RememberFrontApp();
_tookKeyboard = true;
- window.Activate();
+ var handle = window.TryGetPlatformHandle()?.Handle ?? 0;
+ platform.TakeKeyWindow(handle);
+ if (handle == 0)
+ window.Activate();
}
/// The drop opens a window of Filee's own: the keyboard stays with Filee for it, not given back on close.
@@ -771,6 +776,8 @@ private void ReturnKeyboard()
if (!_tookKeyboard)
return;
_tookKeyboard = false;
+ var handle = _window?.TryGetPlatformHandle()?.Handle ?? 0;
+ platform.ReleaseKeyWindow(handle);
platform.ReturnFocus();
}
diff --git a/src/Filee.Core/Platform/IPlatformServices.cs b/src/Filee.Core/Platform/IPlatformServices.cs
index 102f609b..ff86e3d6 100644
--- a/src/Filee.Core/Platform/IPlatformServices.cs
+++ b/src/Filee.Core/Platform/IPlatformServices.cs
@@ -174,6 +174,17 @@ void RememberFrontApp() { }
/// didn't click elsewhere meanwhile) and that app still runs; forgets it either way.
///
void ReturnFocus() { }
+
+ ///
+ /// Makes the window key without activating the entire application, so the window can receive key presses
+ /// (such as the flip key during a drag) while leaving macOS Desktop Reveal / Exposé mode uninterrupted.
+ ///
+ void TakeKeyWindow(nint windowHandle) { }
+
+ ///
+ /// Resigns key window status on a window previously made key, returning key events to the frontmost application.
+ ///
+ void ReleaseKeyWindow(nint windowHandle) { }
}
///
diff --git a/src/Filee.Platform.MacOS/MacOSPlatformServices.cs b/src/Filee.Platform.MacOS/MacOSPlatformServices.cs
index d2af32fc..9c8f5834 100644
--- a/src/Filee.Platform.MacOS/MacOSPlatformServices.cs
+++ b/src/Filee.Platform.MacOS/MacOSPlatformServices.cs
@@ -140,11 +140,19 @@ public void RaiseTopmost(nint windowHandle)
public void RestoreWindow(nint windowHandle)
{
if (windowHandle != 0)
+ {
+ if (_keyWindow == windowHandle)
+ {
+ _keyWindow = 0;
+ NativeMethods.ReleaseKeyWindow(windowHandle);
+ }
NativeMethods.RestoreWindow(windowHandle);
+ }
}
// The app the donut took the keyboard from (its process id), 0 for none.
private int _focusFrom;
+ private nint _keyWindow;
public void RememberFrontApp()
{
@@ -154,12 +162,34 @@ public void RememberFrontApp()
public void ReturnFocus()
{
+ var keyWindow = _keyWindow;
+ _keyWindow = 0;
+ if (keyWindow != 0)
+ NativeMethods.ReleaseKeyWindow(keyWindow);
+
var from = _focusFrom;
_focusFrom = 0;
if (from != 0 && NativeMethods.FrontmostProcessId() == Environment.ProcessId)
NativeMethods.ActivateProcess(from);
}
+ public void TakeKeyWindow(nint windowHandle)
+ {
+ if (windowHandle == 0)
+ return;
+ _keyWindow = windowHandle;
+ NativeMethods.TakeKeyWindow(windowHandle);
+ }
+
+ public void ReleaseKeyWindow(nint windowHandle)
+ {
+ if (windowHandle == 0)
+ return;
+ if (_keyWindow == windowHandle)
+ _keyWindow = 0;
+ NativeMethods.ReleaseKeyWindow(windowHandle);
+ }
+
///
/// Lets drags that offer file paths or file promises in older forms reach the window (the donut), not just ones
/// offering file URLs: other file managers than Finder write those (issue #160).
diff --git a/src/Filee.Platform.MacOS/NativeMethods.cs b/src/Filee.Platform.MacOS/NativeMethods.cs
index f70ad0f0..7ef07628 100644
--- a/src/Filee.Platform.MacOS/NativeMethods.cs
+++ b/src/Filee.Platform.MacOS/NativeMethods.cs
@@ -153,10 +153,10 @@ internal static bool PrefersReducedMotion()
internal static void RaiseTopmost(nint window)
{
- const nint canJoinAllSpaces = 1, moveToActiveSpace = 1 << 1;
+ const nint canJoinAllSpaces = 1, moveToActiveSpace = 1 << 1, stationary = 1 << 4;
const nint fullScreenPrimary = 1 << 7, fullScreenAuxiliary = 1 << 8, fullScreenNone = 1 << 9;
var behavior = Send(window, sel_registerName("collectionBehavior"));
- behavior = (behavior & ~(moveToActiveSpace | fullScreenPrimary | fullScreenNone)) | canJoinAllSpaces | fullScreenAuxiliary;
+ behavior = (behavior & ~(moveToActiveSpace | fullScreenPrimary | fullScreenNone)) | canJoinAllSpaces | stationary | fullScreenAuxiliary;
// macOS 13's primary, auxiliary and canJoinAllApplications: with canJoinAllApplications set the donut still
// stays off a full-screen Finder Space, even as a panel (#251).
behavior &= ~((1 << 16) | (1 << 17) | (1 << 18));
@@ -509,4 +509,38 @@ internal static bool RequestAccessibility()
[DllImport(ObjC, EntryPoint = "objc_msgSend")]
[return: MarshalAs(UnmanagedType.I1)]
private static extern bool SendBoolWithInteger(nint receiver, nint selector, nint value);
+
+ internal static void TakeKeyWindow(nint window)
+ {
+ var selector = sel_registerName("makeKeyAndOrderFront:");
+ if (SendBool(window, sel_registerName("respondsToSelector:"), selector))
+ SendVoid(window, selector, 0);
+ }
+
+ internal static void ReleaseKeyWindow(nint window)
+ {
+ if (!IsKeyWindow(window))
+ return;
+
+ var isVisibleSelector = sel_registerName("isVisible");
+ var wasVisible = SendBool(window, sel_registerName("respondsToSelector:"), isVisibleSelector)
+ && SendBool(window, isVisibleSelector);
+
+ var orderOutSelector = sel_registerName("orderOut:");
+ if (SendBool(window, sel_registerName("respondsToSelector:"), orderOutSelector))
+ SendVoid(window, orderOutSelector, 0);
+
+ if (wasVisible)
+ {
+ var orderFrontSelector = sel_registerName("orderFrontRegardless");
+ if (SendBool(window, sel_registerName("respondsToSelector:"), orderFrontSelector))
+ SendVoid(window, orderFrontSelector);
+ }
+ }
+
+ internal static bool IsKeyWindow(nint window)
+ {
+ var selector = sel_registerName("isKeyWindow");
+ return SendBool(window, sel_registerName("respondsToSelector:"), selector) && SendBool(window, selector);
+ }
}
diff --git a/tests/Filee.App.Tests/RadialControllerTests.cs b/tests/Filee.App.Tests/RadialControllerTests.cs
index e1581ba1..cfe63f4c 100644
--- a/tests/Filee.App.Tests/RadialControllerTests.cs
+++ b/tests/Filee.App.Tests/RadialControllerTests.cs
@@ -9,6 +9,7 @@
using Filee.App.Services;
using Filee.App.Services.Triggers;
using Filee.App.ViewModels;
+using Filee.App.Views;
using Filee.Core.Platform;
using Filee.Core.Presets;
using Filee.Core.Settings;
@@ -73,6 +74,175 @@ public void Closing_a_visible_donut_restores_it_before_the_native_window_is_rele
}
}
+ [AvaloniaFact]
+ public void Files_entering_the_donut_during_drag_takes_and_releases_key_window()
+ {
+ if (!OperatingSystem.IsMacOS())
+ {
+ Assert.Skip("Requires macOS.");
+ return;
+ }
+
+ TestServices.EnsureInitialized("en");
+ var platform = new WindowPlatform();
+ var radial = Create(platform);
+ try
+ {
+ radial.ShowForDrag(400, 400);
+ var window = radial.Window!;
+ var handle = window.TryGetPlatformHandle()?.Handle ?? 0;
+ Assert.Empty(platform.KeyWindowsTaken);
+ Assert.Empty(platform.KeyWindowsReleased);
+
+ var filesEntered = (Action>?)typeof(RadialWindow)
+ .GetField("FilesEntered", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Public)?
+ .GetValue(window);
+ Assert.NotNull(filesEntered);
+
+ filesEntered([@"/tmp/filee-test-drag.png"]);
+
+ Assert.Equal([handle], platform.KeyWindowsTaken);
+ Assert.Equal(1, platform.RememberFrontAppCalls);
+ Assert.Empty(platform.KeyWindowsReleased);
+
+ radial.Close(animate: false);
+
+ Assert.Equal([handle], platform.KeyWindowsReleased);
+ Assert.Equal(1, platform.ReturnFocusCalls);
+ }
+ finally
+ {
+ radial.Close(animate: false);
+ }
+ }
+
+ [AvaloniaFact]
+ public void Dropping_files_on_the_donut_releases_key_window_while_progress_ring_shows()
+ {
+ if (!OperatingSystem.IsMacOS())
+ {
+ Assert.Skip("Requires macOS.");
+ return;
+ }
+
+ TestServices.EnsureInitialized("en");
+ var platform = new WindowPlatform();
+ var radial = Create(platform);
+ var tempFile = Path.Combine(Path.GetTempPath(), "filee-test-drop.png");
+ File.WriteAllBytes(tempFile, [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);
+ try
+ {
+ radial.ShowForDrag(400, 400);
+ var window = radial.Window!;
+ var handle = window.TryGetPlatformHandle()?.Handle ?? 0;
+
+ var filesEntered = (Action>?)typeof(RadialWindow)
+ .GetField("FilesEntered", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Public)?
+ .GetValue(window);
+ Assert.NotNull(filesEntered);
+
+ filesEntered([tempFile]);
+ Assert.Equal([handle], platform.KeyWindowsTaken);
+ Assert.Empty(platform.KeyWindowsReleased);
+
+ var dropped = (Action?)typeof(RadialWindow)
+ .GetField("Dropped", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Public)?
+ .GetValue(window);
+ Assert.NotNull(dropped);
+
+ var model = AppHost.Get();
+ var index = -1;
+ for (var i = 0; i < model.Items.Count; i++)
+ {
+ var preset = model.PresetAt(i, -1);
+ if (preset is not null && !preset.IsTool && !preset.Output.AskEveryTime)
+ {
+ index = i;
+ break;
+ }
+ }
+
+ Assert.True(index >= 0);
+ dropped(new DonutHit(DonutHitKind.Segment, index, -1), new DraggedItems([tempFile], null));
+
+ // Key window status is released immediately on drop so typing returns to previous app
+ Assert.Equal([handle], platform.KeyWindowsReleased);
+ Assert.Equal(1, platform.ReturnFocusCalls);
+ }
+ finally
+ {
+ radial.Close(animate: false);
+ if (File.Exists(tempFile))
+ File.Delete(tempFile);
+ }
+ }
+
+ [AvaloniaFact]
+ public void Drop_opening_owned_window_keeps_keyboard_for_it()
+ {
+ if (!OperatingSystem.IsMacOS())
+ {
+ Assert.Skip("Requires macOS.");
+ return;
+ }
+
+ TestServices.EnsureInitialized("en");
+ var store = AppHost.Get();
+ var profile = store.Profiles.First(p => p.Id == "images");
+ var beforePresets = profile.PresetIds;
+ profile.PresetIds = ["tool-crop", .. beforePresets];
+ var platform = new WindowPlatform();
+ var radial = Create(platform);
+ var tempFile = Path.Combine(Path.GetTempPath(), "filee-test-tool.png");
+ File.WriteAllBytes(tempFile, [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);
+ try
+ {
+ radial.ShowForDrag(400, 400);
+ var window = radial.Window!;
+ var handle = window.TryGetPlatformHandle()?.Handle ?? 0;
+
+ var filesEntered = (Action>?)typeof(RadialWindow)
+ .GetField("FilesEntered", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Public)?
+ .GetValue(window);
+ Assert.NotNull(filesEntered);
+
+ filesEntered([tempFile]);
+ Assert.Equal([handle], platform.KeyWindowsTaken);
+ Assert.Empty(platform.KeyWindowsReleased);
+
+ var dropped = (Action?)typeof(RadialWindow)
+ .GetField("Dropped", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Public)?
+ .GetValue(window);
+ Assert.NotNull(dropped);
+
+ var model = AppHost.Get();
+ var toolIndex = -1;
+ for (var i = 0; i < model.Items.Count; i++)
+ {
+ var preset = model.PresetAt(i, -1);
+ if (preset is not null && preset.IsTool && !preset.IsCopyToFolder)
+ {
+ toolIndex = i;
+ break;
+ }
+ }
+
+ Assert.True(toolIndex >= 0);
+ dropped(new DonutHit(DonutHitKind.Segment, toolIndex, -1), new DraggedItems([tempFile], null));
+
+ // Keyboard stays with Filee for the tool window
+ Assert.Empty(platform.KeyWindowsReleased);
+ Assert.Equal(0, platform.ReturnFocusCalls);
+ }
+ finally
+ {
+ profile.PresetIds = beforePresets;
+ radial.Close(animate: false);
+ if (File.Exists(tempFile))
+ File.Delete(tempFile);
+ }
+ }
+
private static RadialController Create(IPlatformServices platform) =>
new(AppHost.Get(), AppHost.Get(), AppHost.Get(),
AppHost.Get(), platform, AppHost.Get(), NullLogger.Instance);
@@ -80,8 +250,17 @@ private static RadialController Create(IPlatformServices platform) =>
private sealed class WindowPlatform : IPlatformServices
{
public int Restores { get; private set; }
+ public List KeyWindowsTaken { get; } = [];
+ public List KeyWindowsReleased { get; } = [];
+ public int RememberFrontAppCalls { get; private set; }
+ public int ReturnFocusCalls { get; private set; }
+
public void RestoreWindow(nint windowHandle) => Restores++;
public void RaiseTopmost(nint windowHandle) { }
+ public void TakeKeyWindow(nint windowHandle) => KeyWindowsTaken.Add(windowHandle);
+ public void ReleaseKeyWindow(nint windowHandle) => KeyWindowsReleased.Add(windowHandle);
+ public void RememberFrontApp() => RememberFrontAppCalls++;
+ public void ReturnFocus() => ReturnFocusCalls++;
public bool IsFileManagerAt(int x, int y) => false;
public string? ProcessNameAt(int x, int y) => null;
public IReadOnlyList GetFileManagerSelection() => [];
diff --git a/tests/Filee.Platform.Tests/MacOSWindowTests.cs b/tests/Filee.Platform.Tests/MacOSWindowTests.cs
index fbbc688f..4c22abe2 100644
--- a/tests/Filee.Platform.Tests/MacOSWindowTests.cs
+++ b/tests/Filee.Platform.Tests/MacOSWindowTests.cs
@@ -89,7 +89,7 @@ public void Raising_and_restoring_a_window_repeatedly_restores_its_native_class_
{
platform.RaiseTopmost(window);
Assert.Equal(panelClass, ObjectClass(window));
- Assert.Equal((nint)(1 | (1 << 6) | (1 << 8)), behavior);
+ Assert.Equal((nint)(1 | (1 << 4) | (1 << 6) | (1 << 8)), behavior);
Assert.Equal(unrelatedStyle | nonactivatingPanel, style);
Assert.False(hidesOnDeactivate);
Assert.Equal((nint)3, level);
@@ -141,6 +141,111 @@ public void Raising_and_restoring_a_window_repeatedly_restores_its_native_class_
}
}
+ [Fact]
+ public void TakeKeyWindow_and_ReleaseKeyWindow_manage_key_window_state()
+ {
+ if (!OperatingSystem.IsMacOS())
+ {
+ Assert.Skip("Requires macOS.");
+ return;
+ }
+
+ var isKey = false;
+ var isVisible = false;
+ var makeKeyCalls = 0;
+ var orderOutCalls = 0;
+ var orderFrontCalls = 0;
+ var resignKeyCalls = 0;
+
+ SendActionWithArg makeKey = (_, _, _) => { isKey = true; isVisible = true; makeKeyCalls++; };
+ SendActionWithArg orderOut = (_, _, _) => { isKey = false; isVisible = false; orderOutCalls++; };
+ SendAction orderFront = (_, _) => { isVisible = true; orderFrontCalls++; };
+ SendAction resignKey = (_, _) => resignKeyCalls++;
+ GetBool isKeyWindow = (_, _) => isKey;
+ GetBool getIsVisible = (_, _) => isVisible;
+ Delegate[] callbacks = [makeKey, orderOut, orderFront, resignKey, isKeyWindow, getIsVisible];
+
+ var testClass = AllocateClass(GetClass("NSObject"), "FileeKeyWindowTestWindow", 0);
+ Assert.NotEqual(0, testClass);
+ nint window = 0;
+ try
+ {
+ AddMethod(testClass, "makeKeyAndOrderFront:", makeKey, "v@:@");
+ AddMethod(testClass, "orderOut:", orderOut, "v@:@");
+ AddMethod(testClass, "orderFrontRegardless", orderFront, "v@:");
+ AddMethod(testClass, "resignKeyWindow", resignKey, "v@:");
+ AddMethod(testClass, "isKeyWindow", isKeyWindow, "B@:");
+ AddMethod(testClass, "isVisible", getIsVisible, "B@:");
+ RegisterClass(testClass);
+
+ window = Send(Send(testClass, Selector("alloc")), Selector("init"));
+ Assert.NotEqual(0, window);
+
+ var platform = new MacOSPlatformServices();
+ Assert.False(isKey);
+ Assert.False(NativeMethods.IsKeyWindow(window));
+
+ // 1. TakeKeyWindow sends makeKeyAndOrderFront:
+ platform.TakeKeyWindow(window);
+ Assert.True(isKey);
+ Assert.True(isVisible);
+ Assert.True(NativeMethods.IsKeyWindow(window));
+ Assert.Equal(1, makeKeyCalls);
+ Assert.Equal(0, orderOutCalls);
+ Assert.Equal(0, resignKeyCalls);
+
+ // 2. ReleaseKeyWindow ends key status via orderOut: and restores visibility via orderFrontRegardless
+ platform.ReleaseKeyWindow(window);
+ Assert.False(isKey);
+ Assert.True(isVisible);
+ Assert.False(NativeMethods.IsKeyWindow(window));
+ Assert.Equal(1, makeKeyCalls);
+ Assert.Equal(1, orderOutCalls);
+ Assert.Equal(1, orderFrontCalls);
+ Assert.Equal(0, resignKeyCalls); // resignKeyWindow is never invoked directly per Apple guidelines
+
+ // 3. ReturnFocus releases key window if TakeKeyWindow was called
+ platform.TakeKeyWindow(window);
+ Assert.True(isKey);
+ Assert.True(NativeMethods.IsKeyWindow(window));
+ Assert.Equal(2, makeKeyCalls);
+
+ platform.ReturnFocus();
+ Assert.False(isKey);
+ Assert.True(isVisible);
+ Assert.False(NativeMethods.IsKeyWindow(window));
+ Assert.Equal(2, orderOutCalls);
+ Assert.Equal(2, orderFrontCalls);
+ Assert.Equal(0, resignKeyCalls);
+
+ // 4. RestoreWindow releases key window if still tracked
+ platform.TakeKeyWindow(window);
+ Assert.True(isKey);
+ Assert.True(NativeMethods.IsKeyWindow(window));
+ Assert.Equal(3, makeKeyCalls);
+
+ platform.RestoreWindow(window);
+ Assert.False(isKey);
+ Assert.False(NativeMethods.IsKeyWindow(window));
+ Assert.Equal(3, orderOutCalls);
+ Assert.Equal(0, resignKeyCalls);
+
+ // 5. Handles zero safely
+ platform.TakeKeyWindow(0);
+ platform.ReleaseKeyWindow(0);
+ platform.RestoreWindow(0);
+ platform.ReturnFocus();
+ }
+ finally
+ {
+ if (window != 0)
+ Send(window, Selector("release"));
+ if (testClass != 0)
+ DisposeClass(testClass);
+ GC.KeepAlive(callbacks);
+ }
+ }
+
private static nint RegisterSubclass(nint superclass, string name)
{
var result = AllocateClass(superclass, name, 0);
@@ -161,6 +266,13 @@ private static void AddMethod(nint cls, string name, Delegate callback, string s
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate void SendAction(nint receiver, nint selector);
+ [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
+ private delegate void SendActionWithArg(nint receiver, nint selector, nint arg);
+
+ [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
+ [return: MarshalAs(UnmanagedType.I1)]
+ private delegate bool GetBool(nint receiver, nint selector);
+
private const string ObjC = "/usr/lib/libobjc.A.dylib";
[DllImport(ObjC, EntryPoint = "objc_getClass")]
@@ -188,4 +300,7 @@ private static extern bool AddNativeMethod(nint cls, nint selector, nint impleme
[DllImport(ObjC, EntryPoint = "objc_msgSend")]
private static extern nint Send(nint receiver, nint selector);
+
+ [DllImport(ObjC, EntryPoint = "objc_msgSend")]
+ private static extern nint Send(nint receiver, nint selector, nint arg);
}