diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a327812d..5eca5482 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -140,7 +140,14 @@ flipped to, and only then does the hub leave out its "⟳ Tab back" hint (a mous wheel flips). In click mode the focused window gets the key (`DonutMenu.HandleKey`) and the donut the wheel. During a 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. +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. 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 c3cf4294..f114c5a3 100644 --- a/src/Filee.App/Controls/DonutMenu.Flip.cs +++ b/src/Filee.App/Controls/DonutMenu.Flip.cs @@ -5,7 +5,9 @@ // The flip key (Tab unless the user records another in Settings → Shortcuts) and the mouse wheel flip it: // 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). +// 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. // // 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. @@ -85,8 +87,8 @@ private sealed record OldGroup(int Index, IReadOnlyList Children, dou public bool FlipWithWheel { get => GetValue(FlipWithWheelProperty); set => SetValue(FlipWithWheelProperty, value); } /// - /// Drag mode: the global hook flips on the flip key (RadialController), so the window's own key press doesn't flip - /// too. macOS can give the donut's window the keyboard during a drag, and one press then flipped twice (#504). + /// The global hook flips on the flip key (RadialController: drag mode, while the window doesn't have the keyboard), + /// so the window's own key press doesn't flip too; one press flipped twice otherwise (#504). /// public bool FlipKeyFromHook { get; set; } diff --git a/src/Filee.App/Services/RadialController.cs b/src/Filee.App/Services/RadialController.cs index 2ef6fdb2..01b741c8 100644 --- a/src/Filee.App/Services/RadialController.cs +++ b/src/Filee.App/Services/RadialController.cs @@ -186,6 +186,7 @@ public void Close(bool animate = true) _window.DonutControl.LeaveProgressMode(); } _visible = false; + ReturnKeyboard(); WatchFlipKey(); _grace.Cancel(); ReplaceWeb(null); @@ -261,6 +262,7 @@ private RadialWindow EnsureWindow() return; viewModel.Load(files, clickMode: false); donut.PlayOpenAnimation(); + TakeKeyboard(window); WatchFlipKey(); }; window.Dropped += (hit, dropped) => @@ -283,6 +285,13 @@ private RadialWindow EnsureWindow() log.LogInformation("Drag over the donut: {Events} move event(s), ended on {Kind} {Index}, drawn lit {Lit:0.00}{Drop}", events, hit.Kind, hit.Index, lit, dropped ? "" : ", left without a drop"); window.EscapePressed += () => Close(); + // The flip key comes from the window while it has the keyboard, else from the hook (WatchFlipKey): followed by + // the value, which the Activated event may come before. + window.PropertyChanged += (_, e) => + { + if (e.Property == Avalonia.Controls.WindowBase.IsActiveProperty) + WatchFlipKey(); + }; window.Deactivated += (_, _) => { if (!viewModel.IsClickMode || _editing) @@ -301,10 +310,11 @@ private RadialWindow EnsureWindow() }; donut.ActiveIndexChanged += (_, index) => viewModel.ShowHint(index, donut.ActiveSubIndex, donut.IsCenterHot); - // Drag mode: the flip key and the wheel seen by the global hook (click mode gets them in the focused window). + // Drag mode: the flip key and the wheel seen by the global hook (click mode gets them in the focused window, and so + // does a drag-mode donut that has the keyboard: see TakeKeyboard). triggers.FlipKeyPressed += (_, _) => { - if (FlipsFromHook) + if (HookFlipsKey) donut.Flip(); }; triggers.FlipWheelTurned += (_, _) => @@ -352,6 +362,7 @@ private void Convert(int index, int sub, IReadOnlyList files, WebDrop? w { log.LogInformation("Opening {Preset} for {Count} file(s)", preset.Id, sources.Count); var near = _window?.Position; + KeepKeyboard(); // for the tool's window Close(animate: false); if (web is null) windows.ShowTool(sources, preset, near); @@ -363,6 +374,7 @@ private void Convert(int index, int sub, IReadOnlyList files, WebDrop? w // Merged PDFs, joined clips, slideshows and merged pictures follow the order the user picks. if ((preset.TargetFormat == "pdf" && preset.Pdf.MergeIntoSingle || preset.JoinsIntoOneMedia || preset.JoinsImages) && sources.Count > 1) { + KeepKeyboard(); // for the order window Close(animate: false); _ = MergeInOrderAsync(sources, preset.Clone(), web, _toClipboard); return; @@ -372,6 +384,7 @@ private void Convert(int index, int sub, IReadOnlyList files, WebDrop? w // shortcut: files from a browser aren't asked about, they save into the download folder as "next to the source". if (preset.Output.AskEveryTime && (web is null || preset.IsCopyToFolder)) { + KeepKeyboard(); // for the save dialog Close(animate: false); _ = AskThenConvertAsync(sources, preset, _toClipboard, web); return; @@ -387,6 +400,7 @@ private void Convert(int index, int sub, IReadOnlyList files, WebDrop? w } _converted = true; ShowProgress(job); + ReturnKeyboard(); // the ring shows the conversion; nothing here needs the keyboard any more } private async Task MergeInOrderAsync(IReadOnlyList files, Preset preset, WebDrop? web, bool toClipboard) @@ -540,6 +554,7 @@ private void DetachProgress() private void HideWindow() { _visible = false; + ReturnKeyboard(); WatchFlipKey(); _window?.DonutControl.CollapseGroup(); _window?.Hide(); @@ -574,14 +589,51 @@ private async Task EditPresetAsync(int index, int sub = -1) private void WatchFlipKey() { triggers.WatchFlipKey = FlipsFromHook; - // One source per press: the hook in drag mode, the window in click mode (#504). + // One source per press (#504): the window while it has the keyboard (click mode, or a drag-mode donut that took + // it), else the hook. if (_window is not null) - _window.DonutControl.FlipKeyFromHook = FlipsFromHook; + _window.DonutControl.FlipKeyFromHook = HookFlipsKey; } /// A drag-mode donut shows files and nothing was dropped yet: the hook's flip key and wheel flip it. private bool FlipsFromHook => _visible && !_dropped && !viewModel.IsClickMode && viewModel.HasFiles; + /// The hook's flip key flips the donut: in drag mode, while its window doesn't have the keyboard. + private bool HookFlipsKey => FlipsFromHook && _window?.IsActive != true; + + // The donut took the keyboard during a drag (TakeKeyboard) and hasn't given it back yet. + 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 (). + /// Not on Windows, where a window taking the keyboard during a drag breaks Explorer's drag. + /// + private void TakeKeyboard(RadialWindow window) + { + if (!OperatingSystem.IsMacOS() || _tookKeyboard || window.IsActive) + return; + platform.RememberFrontApp(); + _tookKeyboard = true; + window.Activate(); + } + + /// The drop opens a window of Filee's own: the keyboard stays with Filee for it, not given back on close. + private void KeepKeyboard() => _tookKeyboard = false; + + /// Gives the keyboard back to the app the donut took it from, if the donut still has it. + private void ReturnKeyboard() + { + if (!_tookKeyboard) + return; + _tookKeyboard = false; + platform.ReturnFocus(); + } + /// /// Top-left corner of a square window centred on , moved into /// when that would cross its edge. With group slices the window holds the ring they fan out diff --git a/src/Filee.App/Services/Triggers/TriggerService.cs b/src/Filee.App/Services/Triggers/TriggerService.cs index 16c12357..0b7d5ee2 100644 --- a/src/Filee.App/Services/Triggers/TriggerService.cs +++ b/src/Filee.App/Services/Triggers/TriggerService.cs @@ -201,10 +201,10 @@ public void Start() _hook.MousePressed += (_, e) => { if (Trace) - _log.LogInformation("Trace: press {Button} at {X},{Y}, mask {Mask} = {Modifiers}", e.Data.Button, e.Data.X, e.Data.Y, e.RawEvent.Mask, Map(e.RawEvent.Mask)); + _log.LogInformation("Trace: press {Button} at {X},{Y}, mask {Mask} = {Modifiers}", e.Data.Button, e.Data.X, e.Data.Y, e.RawEvent.Mask, ModifiersOf(e.RawEvent.Mask)); var (x, y) = ButtonPoint(e); (_pressX, _pressY, _heldMoves, _farthest) = (x, y, 0, 0); - _detector.MouseDown(Map(e.Data.Button), x, y, Map(e.RawEvent.Mask)); + _detector.MouseDown(Map(e.Data.Button), x, y, ModifiersOf(e.RawEvent.Mask)); }; _hook.MouseReleased += (_, e) => { @@ -341,7 +341,7 @@ private void OnMove(MouseHookEventArgs e) _heldMoves++; _farthest = Math.Max(_farthest, Math.Max(Math.Abs(e.Data.X - _pressX), Math.Abs(e.Data.Y - _pressY))); } - _detector.MouseMove(e.Data.X, e.Data.Y, Map(e.RawEvent.Mask), HeldButton(e.RawEvent.Mask)); + _detector.MouseMove(e.Data.X, e.Data.Y, MoveModifiers(e.RawEvent.Mask), HeldButton(e.RawEvent.Mask)); } private void OnRelativeMove(MouseHookEventArgs e) @@ -350,7 +350,7 @@ private void OnRelativeMove(MouseHookEventArgs e) _estY += e.Data.Y; if (Trace && HeldButton(e.RawEvent.Mask) != TriggerMouseButton.None) _log.LogInformation("Trace: relative move {Dx},{Dy} -> {X},{Y}, mask {Mask}", e.Data.X, e.Data.Y, _estX, _estY, e.RawEvent.Mask); - _detector.MouseMove(_estX, _estY, Map(e.RawEvent.Mask), HeldButton(e.RawEvent.Mask)); + _detector.MouseMove(_estX, _estY, MoveModifiers(e.RawEvent.Mask), HeldButton(e.RawEvent.Mask)); } internal bool InScope(TriggerGesture gesture, int x, int y) @@ -380,6 +380,15 @@ internal bool InScope(TriggerGesture gesture, int x, int y) _ => TriggerMouseButton.None, }; + /// + /// The modifiers a mouse event was made with: what the platform reports held (macOS, whose hook mask can lose them), + /// else the hook's mask. Only asked while it matters: at a press, and on moves with a button held. + /// + private ModifierKeys ModifiersOf(EventMask mask) => _platform.CurrentModifiers() ?? Map(mask); + + // A move's modifiers only matter while a button is held (a modifier pressed after the button): not asked otherwise. + private ModifierKeys MoveModifiers(EventMask mask) => HeldButton(mask) == TriggerMouseButton.None ? Map(mask) : ModifiersOf(mask); + internal static ModifierKeys Map(EventMask mask) { var result = ModifierKeys.None; diff --git a/src/Filee.Core/Platform/IPlatformServices.cs b/src/Filee.Core/Platform/IPlatformServices.cs index b999edca..102f609b 100644 --- a/src/Filee.Core/Platform/IPlatformServices.cs +++ b/src/Filee.Core/Platform/IPlatformServices.cs @@ -1,3 +1,5 @@ +using Filee.Core.Settings; + namespace Filee.Core.Platform; /// Operating-system specific services. @@ -36,6 +38,12 @@ public interface IPlatformServices /// ForegroundWindowInfo? GetForegroundWindow() => null; + /// + /// The modifier keys held now, as the system reports them, for matching mouse gestures; null where the global hook's + /// own event mask is right (it isn't on macOS while another app holds Secure Input, see the macOS implementation). + /// + ModifierKeys? CurrentModifiers() => null; + /// /// Names of the apps that have a window open now, as reports them, sorted, without /// Filee. Windows reports a main window per process; macOS and Linux list the windows on screen. @@ -154,6 +162,18 @@ void OpenWith(string path) { } /// Restores temporary native window configuration after a raised window hides or before it closes. void RestoreWindow(nint windowHandle) { } + + /// + /// Remembers the app in front now, so can give it the keyboard back: on macOS the donut + /// takes the keyboard during a drag. Elsewhere, and when this app is the one in front, nothing is remembered. + /// + void RememberFrontApp() { } + + /// + /// Gives the keyboard back to the app remembered, if this app still has it (the user + /// didn't click elsewhere meanwhile) and that app still runs; forgets it either way. + /// + void ReturnFocus() { } } /// diff --git a/src/Filee.Platform.MacOS/MacOSPlatformServices.cs b/src/Filee.Platform.MacOS/MacOSPlatformServices.cs index 100bedad..d2af32fc 100644 --- a/src/Filee.Platform.MacOS/MacOSPlatformServices.cs +++ b/src/Filee.Platform.MacOS/MacOSPlatformServices.cs @@ -3,6 +3,7 @@ using System.Text; using System.Text.Json; using Filee.Core.Platform; +using Filee.Core.Settings; namespace Filee.Platform.MacOS; @@ -41,6 +42,12 @@ public bool IsFileManagerAt(int x, int y) => public string? ProcessNameAt(int x, int y) => NativeMethods.ProcessNameAt(x, y); + // SharpHook's libuiohook rebuilds its modifier mask on every event with CGEventSourceKeyState, which answers "up" for + // held keys while another app holds Secure Input (Terminal's Secure Keyboard Entry, a password field): every press + // then looks modifier-free and no ⌥ / ⌘ gesture matches. The flags state stays right, as it does for AppKit's + // NSEvent.modifierFlags. Drop this once SharpHook reads the flags state itself. + public ModifierKeys? CurrentModifiers() => NativeMethods.CurrentModifiers(); + public IReadOnlyList RunningAppNames() => IPlatformServices.AppNames(NativeMethods.WindowOwnerNames().ToList()); public IReadOnlyList GetFileManagerSelection() @@ -136,6 +143,23 @@ public void RestoreWindow(nint windowHandle) NativeMethods.RestoreWindow(windowHandle); } + // The app the donut took the keyboard from (its process id), 0 for none. + private int _focusFrom; + + public void RememberFrontApp() + { + var front = NativeMethods.FrontmostProcessId(); + _focusFrom = front != Environment.ProcessId ? front : 0; + } + + public void ReturnFocus() + { + var from = _focusFrom; + _focusFrom = 0; + if (from != 0 && NativeMethods.FrontmostProcessId() == Environment.ProcessId) + NativeMethods.ActivateProcess(from); + } + /// /// 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 8fe14deb..f70ad0f0 100644 --- a/src/Filee.Platform.MacOS/NativeMethods.cs +++ b/src/Filee.Platform.MacOS/NativeMethods.cs @@ -1,6 +1,7 @@ using System.Diagnostics; using System.Runtime.InteropServices; using System.Runtime.Versioning; +using Filee.Core.Settings; namespace Filee.Platform.MacOS; @@ -22,6 +23,18 @@ internal static class NativeMethods private static nint CoreFoundationConstant(string name) => Marshal.ReadIntPtr(NativeLibrary.GetExport(NativeLibrary.Load(CoreFoundation), name)); + /// The modifier keys held now (kCGEventSourceStateCombinedSessionState: every keyboard, including posted keys). + internal static ModifierKeys CurrentModifiers() + { + var flags = CGEventSourceFlagsState(0); + var keys = ModifierKeys.None; + if ((flags & 0x20000) != 0) keys |= ModifierKeys.Shift; // kCGEventFlagMaskShift + if ((flags & 0x40000) != 0) keys |= ModifierKeys.Ctrl; // kCGEventFlagMaskControl + if ((flags & 0x80000) != 0) keys |= ModifierKeys.Alt; // kCGEventFlagMaskAlternate (Option) + if ((flags & 0x100000) != 0) keys |= ModifierKeys.Meta; // kCGEventFlagMaskCommand + return keys; + } + /// Process names of the app windows on screen (layer 0), front to back. internal static IEnumerable WindowOwnerNames() { @@ -113,6 +126,24 @@ internal static bool IsFinderFrontmost() return Marshal.PtrToStringUTF8(Send(bundle, sel_registerName("UTF8String"))) == "com.apple.finder"; } + /// The process id of the app in front (NSWorkspace.frontmostApplication), 0 if none. + internal static int FrontmostProcessId() + { + using var pool = new AutoreleasePool(); + var workspace = Send(objc_getClass("NSWorkspace"), sel_registerName("sharedWorkspace")); + var application = Send(workspace, sel_registerName("frontmostApplication")); + return application == 0 ? 0 : (int)Send(application, sel_registerName("processIdentifier")); + } + + /// Brings the app with process id to the front, if it still runs. + internal static void ActivateProcess(int pid) + { + using var pool = new AutoreleasePool(); + var application = Send(objc_getClass("NSRunningApplication"), sel_registerName("runningApplicationWithProcessIdentifier:"), pid); + if (application != 0) + SendBool(application, sel_registerName("activateWithOptions:"), 0); + } + internal static bool PrefersReducedMotion() { using var pool = new AutoreleasePool(); @@ -410,6 +441,9 @@ internal static bool RequestAccessibility() [DllImport(CoreGraphics)] private static extern nint CGWindowListCopyWindowInfo(uint option, uint relativeToWindow); + [DllImport(CoreGraphics)] + private static extern ulong CGEventSourceFlagsState(int stateId); + [DllImport(CoreGraphics)] [return: MarshalAs(UnmanagedType.I1)] private static extern bool CGRectMakeWithDictionaryRepresentation(nint dictionary, out CGRect rect); diff --git a/tests/Filee.App.Tests/DonutFlipTests.cs b/tests/Filee.App.Tests/DonutFlipTests.cs index 2a71f00c..65d684bc 100644 --- a/tests/Filee.App.Tests/DonutFlipTests.cs +++ b/tests/Filee.App.Tests/DonutFlipTests.cs @@ -869,6 +869,7 @@ public void In_drag_mode_tab_from_the_global_hook_flips_the_donut() => With(anim Assert.Equal(new[] { file }, vm.Files); Assert.False(vm.IsBackFace); Assert.True(triggers.WatchFlipKey); + var other = TakeKeyboardAway(window); provider.PostKey(EventType.KeyPressed, KeyCode.VcTab, EventMask.LeftCtrl); // #504: macOS can hand the donut's window the same press as well; it must not flip a second time. @@ -887,6 +888,96 @@ public void In_drag_mode_tab_from_the_global_hook_flips_the_donut() => With(anim provider.PostKey(EventType.KeyPressed, KeyCode.VcTab); Assert.False(TriggerServiceLifecycleTests.Settle(() => vm.IsBackFace)); window.Close(); + other?.Close(); + } + finally + { + folder.Delete(recursive: true); + } + }); + + /// + /// macOS: these tests are about the hook, which flips while the donut's window doesn't have the keyboard, as on + /// Windows and Linux; on macOS the donut takes it once files are on it (see the test after this one). Another window + /// takes it back; null where the donut didn't have it. + /// + private static Window? TakeKeyboardAway(RadialWindow window) + { + if (!window.IsActive) + return null; + // Showing another headless window doesn't take the keyboard from this one: the platform says it lost it, through + // the callback Avalonia keeps on the window's platform half (not public). + var impl = window.PlatformImpl!; + var deactivated = impl.GetType().GetInterfaces().Append(impl.GetType()) + .Select(t => t.GetProperty("Deactivated", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic)) + .First(p => p is not null)!; + ((Action)deactivated.GetValue(impl)!).Invoke(); + Dispatcher.UIThread.RunJobs(); + Assert.False(window.IsActive); + return null; + } + + /// + /// macOS: once files are dragged onto the donut it takes the keyboard, so the flip key reaches its window as a key + /// press, which works while another app holds Secure Input and hides every key from the hook. One flip per press: + /// the hook's copy of it is ignored meanwhile. The keyboard goes back to the app that had it on the drop or when the + /// donut closes, and the hook takes over again if the user clicks elsewhere. + /// + [AvaloniaFact] + public void On_macos_the_drag_donut_takes_the_keyboard_so_the_flip_key_reaches_it() => With(animations: false, back: null, () => + { + Assert.SkipUnless(OperatingSystem.IsMacOS(), "The donut takes the keyboard during a drag on macOS only"); + var provider = new TriggerServiceLifecycleTests.DelayedProvider(); + provider.AllowExit.Set(); + using var triggers = TriggerServiceLifecycleTests.Create(provider, () => { }); + triggers.Start(); + Assert.True(SpinWait.SpinUntil(() => provider.Runs == 1, 2000)); + var vm = AppHost.Get(); + var platform = new TriggerServiceLifecycleTests.NullPlatformServicesForHooks(); + var radial = new RadialController(vm, AppHost.Get(), AppHost.Get(), Store, + platform, triggers, NullLogger.Instance); + var folder = Directory.CreateTempSubdirectory("filee-flip-"); + try + { + var file = Path.Combine(folder.FullName, "photo.jpg"); + File.WriteAllBytes(file, [0xFF, 0xD8, 0xFF]); + radial.ShowForDrag(400, 400); + var window = radial.Window!; + RenderTests.Pump(); // laid out: the drag enters where the donut is + // A modifier-drag without files (a rubber-band selection) takes nothing. + Assert.False(window.IsActive); + Assert.Equal(0, platform.Remembered); + + var data = new DataTransfer(); + data.Add(DataTransferItem.CreateFile(window.StorageProvider.TryGetFileFromPathAsync(file).GetAwaiter().GetResult()!)); + window.DragDrop(new Point(window.Bounds.Width / 2 + 60, window.Bounds.Height / 2), RawDragEventType.DragEnter, data, + DragDropEffects.Copy, RawInputModifiers.Control); + Dispatcher.UIThread.RunJobs(); + Assert.True(window.IsActive); + Assert.Equal(1, platform.Remembered); + + // Secure Input: only the window gets the press, and it flips on its own. + window.KeyPress(Key.Tab, RawInputModifiers.Control, PhysicalKey.Tab, null); + Dispatcher.UIThread.RunJobs(); + Assert.True(vm.IsBackFace); + // Without it the hook sees the same press too, and doesn't flip a second time (#504). + window.KeyPress(Key.Tab, RawInputModifiers.Control, PhysicalKey.Tab, null); + provider.PostKey(EventType.KeyPressed, KeyCode.VcTab, EventMask.LeftCtrl); + Dispatcher.UIThread.RunJobs(); + Assert.False(TriggerServiceLifecycleTests.Settle(() => vm.IsBackFace)); + provider.PostKey(EventType.KeyReleased, KeyCode.VcTab, EventMask.LeftCtrl); + + // The user clicked elsewhere: the hook flips again. + var other = TakeKeyboardAway(window); + provider.PostKey(EventType.KeyPressed, KeyCode.VcTab, EventMask.LeftCtrl); + Assert.True(TriggerServiceLifecycleTests.Settle(() => vm.IsBackFace)); + + radial.Close(animate: false); + Assert.Equal(1, platform.Returned); + radial.Close(animate: false); // given back once + Assert.Equal(1, platform.Returned); + window.Close(); + other?.Close(); } finally { @@ -1001,6 +1092,7 @@ public void In_drag_mode_the_wheel_and_the_set_key_from_the_global_hook_flip_the DragDropEffects.Copy, RawInputModifiers.Control); Dispatcher.UIThread.RunJobs(); Assert.True(triggers.WatchFlipKey); + using var away = new Disposer(TakeKeyboardAway(window)); // Tab isn't the flip key now; F2 is. provider.PostKey(EventType.KeyPressed, KeyCode.VcTab, EventMask.LeftCtrl); @@ -1300,4 +1392,90 @@ public void The_editor_switches_between_the_front_and_the_back(string language, Store.SaveLibrary(); } }, language); + + /// + /// macOS: the keyboard the drag donut took goes back to the app that had it once a conversion starts, but stays with + /// Filee when the drop opens a window of its own (here the crop tool), or that window would open behind the app. + /// + [AvaloniaFact] + public void On_macos_the_keyboard_goes_back_after_a_conversion_and_stays_for_a_tools_window() => With(animations: false, back: null, () => + { + Assert.SkipUnless(OperatingSystem.IsMacOS(), "The donut takes the keyboard during a drag on macOS only"); + var provider = new TriggerServiceLifecycleTests.DelayedProvider(); + provider.AllowExit.Set(); + using var triggers = TriggerServiceLifecycleTests.Create(provider, () => { }); + var vm = AppHost.Get(); + var platform = new TriggerServiceLifecycleTests.NullPlatformServicesForHooks(); + var radial = new RadialController(vm, AppHost.Get(), AppHost.Get(), Store, + platform, triggers, NullLogger.Instance); + var conversions = AppHost.Get(); + var folder = Directory.CreateTempSubdirectory("filee-flip-"); + ConversionJob? started = null; + try + { + var file = Path.Combine(folder.FullName, "photo.jpg"); + File.WriteAllBytes(file, [0xFF, 0xD8, 0xFF]); + + // A slice that converts: the keyboard goes back once the ring shows the conversion. + var (window, data) = DragOnto(radial, file); + var donut = window.DonutControl; + var convert = Enumerable.Range(0, vm.Items.Count).First(i => vm.PresetAt(i, -1) is { IsTool: false, IsGroup: false }); + var at = donut.TranslatePoint(At(donut, DonutGeometry.MidAngle(convert, vm.Items.Count)), window)!.Value; + var before = conversions.Jobs.Select(j => j.Job.Id).ToHashSet(); + window.DragDrop(at, RawDragEventType.DragOver, data, DragDropEffects.Copy, RawInputModifiers.Control); + window.DragDrop(at, RawDragEventType.Drop, data, DragDropEffects.Copy, RawInputModifiers.Control); + var job = Assert.Single(conversions.Jobs, j => !before.Contains(j.Job.Id)); + started = job.Job; + job.CancelCommand.Execute(null); + Assert.Equal(1, platform.Remembered); + Assert.Equal(1, platform.Returned); + radial.Close(animate: false); + Assert.Equal(1, platform.Returned); // given back once + + // The crop tool on the back face: its window gets the keyboard, so it isn't given back. + (window, data) = DragOnto(radial, file); + donut = window.DonutControl; + Assert.Equal(2, platform.Remembered); + window.KeyPress(Key.Tab, RawInputModifiers.Control, PhysicalKey.Tab, null); + Dispatcher.UIThread.RunJobs(); + Assert.True(vm.IsBackFace); + var crop = Enumerable.Range(0, vm.Items.Count).First(i => vm.PresetAt(i, -1) is { IsCropTool: true }); + at = donut.TranslatePoint(At(donut, DonutGeometry.MidAngle(crop, vm.Items.Count)), window)!.Value; + window.DragDrop(at, RawDragEventType.DragOver, data, DragDropEffects.Copy, RawInputModifiers.Control); + window.DragDrop(at, RawDragEventType.Drop, data, DragDropEffects.Copy, RawInputModifiers.Control); + Dispatcher.UIThread.RunJobs(); + Assert.False(radial.IsVisible); + Assert.Equal(1, platform.Returned); + window.Close(); + } + finally + { + radial.Close(animate: false); + (typeof(WindowService).GetField("_cropOverlay", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)! + .GetValue(AppHost.Get()) as Window)?.Close(); + if (started is not null) + ClipWindowTests.WaitFor(() => started.State is not (JobState.Queued or JobState.Running)); + try { folder.Delete(recursive: true); } catch (IOException) { } + } + }); + + // A drag of file onto the donut, laid out first so the drag enters where the donut is. + private static (RadialWindow Window, DataTransfer Data) DragOnto(RadialController radial, string file) + { + radial.ShowForDrag(400, 400); + var window = radial.Window!; + RenderTests.Pump(); + var data = new DataTransfer(); + data.Add(DataTransferItem.CreateFile(window.StorageProvider.TryGetFileFromPathAsync(file).GetAwaiter().GetResult()!)); + window.DragDrop(new Point(window.Bounds.Width / 2 + 60, window.Bounds.Height / 2), RawDragEventType.DragEnter, data, + DragDropEffects.Copy, RawInputModifiers.Control); + Dispatcher.UIThread.RunJobs(); + Assert.True(window.IsActive); + return (window, data); + } + + private sealed class Disposer(Window? window) : IDisposable + { + public void Dispose() => window?.Close(); + } } diff --git a/tests/Filee.App.Tests/TriggerServiceLifecycleTests.cs b/tests/Filee.App.Tests/TriggerServiceLifecycleTests.cs index 549c8249..e0633e35 100644 --- a/tests/Filee.App.Tests/TriggerServiceLifecycleTests.cs +++ b/tests/Filee.App.Tests/TriggerServiceLifecycleTests.cs @@ -1,6 +1,7 @@ // Verifies that retrying gestures waits for the previous native hook to release its shared callback, that // Wayland's relative mouse motion still starts a drag gesture, and that Tab is reported (to flip the drag donut) only -// while asked for. +// while asked for; and that on macOS a press takes the modifiers the system reports when the hook's mask lost them +// (another app holding Secure Input). using Avalonia.Headless.XUnit; using Avalonia.Threading; @@ -172,6 +173,44 @@ public void Wayland_absolute_pointer_moves_set_the_estimate() Assert.Equal((110, 200), service.LastCursor); } + [AvaloniaFact] + public void A_press_takes_the_modifiers_the_platform_reports_when_the_hook_mask_lost_them() + { + // macOS while another app holds Secure Input: the hook's mask has the button but no modifier, while the system + // still reports Ctrl held. + var (provider, _, started) = StartGesture(wayland: false, modifiers: ModifierKeys.Ctrl); + provider.PostMouse(EventType.MousePressed, 50, 60, EventMask.Button1); + provider.PostMouse(EventType.MouseDragged, 60, 60, EventMask.Button1); + Assert.Equal((60, 60), (WaitForStart(started).X, WaitForStart(started).Y)); + } + + [AvaloniaFact] + public void A_modifier_pressed_after_the_button_counts_when_the_platform_reports_it() + { + var platform = new NullPlatformServicesForHooks { Modifiers = ModifierKeys.None }; + var (provider, _, started) = StartGesture(wayland: false, platform: platform); + provider.PostMouse(EventType.MousePressed, 50, 60, EventMask.Button1); + Assert.Null(Settle(started)); // the press is handled (on the hook's thread) before Ctrl goes down + platform.Modifiers = ModifierKeys.Ctrl; // Ctrl pressed while the button is held; the hook's mask doesn't show it + provider.PostMouse(EventType.MouseDragged, 52, 60, EventMask.Button1); + provider.PostMouse(EventType.MouseDragged, 60, 60, EventMask.Button1); + WaitForStart(started); + } + + [AvaloniaFact] + public void Without_a_platform_reading_the_hook_mask_decides() + { + // Windows and Linux report nothing: a press without the modifier in its mask arms nothing, one with it drags. + var (provider, _, started) = StartGesture(wayland: false); + provider.PostMouse(EventType.MousePressed, 50, 60, EventMask.Button1); + provider.PostMouse(EventType.MouseDragged, 60, 60, EventMask.Button1); + provider.PostMouse(EventType.MouseReleased, 60, 60, EventMask.None); + Assert.Null(Settle(started)); + provider.PostMouse(EventType.MousePressed, 50, 60, Held); + provider.PostMouse(EventType.MouseDragged, 60, 60, Held); + WaitForStart(started); + } + [AvaloniaFact] public void Off_Wayland_presses_and_the_cursor_keep_their_coordinates() { @@ -252,11 +291,13 @@ internal static T Settle(Func read) return read(); } - private static (DelayedProvider Provider, TriggerService Service, Func Started) StartGesture(bool wayland) + private static (DelayedProvider Provider, TriggerService Service, Func Started) StartGesture(bool wayland, + ModifierKeys? modifiers = null, NullPlatformServicesForHooks? platform = null) { var provider = new DelayedProvider(); provider.AllowExit.Set(); - var service = Create(provider, () => { }, wayland); + var service = Create(provider, () => { }, wayland, + platform ?? new NullPlatformServicesForHooks { PressCarriesPosition = !wayland, Modifiers = modifiers }); // Ctrl + left drag on every OS: the default drag gesture uses Option on macOS, which these presses don't hold. service.Apply(new AppSettings { Triggers = [new TriggerGesture { Modifiers = ModifierKeys.Ctrl }] }); GestureEventArgs? started = null; @@ -272,8 +313,9 @@ private static GestureEventArgs WaitForStart(Func started) return started()!; } - internal static TriggerService Create(DelayedProvider provider, Action key, bool wayland = false) => - new(new NullPlatformServicesForHooks { PressCarriesPosition = !wayland }, NullLogger.Instance, () => + internal static TriggerService Create(DelayedProvider provider, Action key, bool wayland = false, + NullPlatformServicesForHooks? platform = null) => + new(platform ?? new NullPlatformServicesForHooks { PressCarriesPosition = !wayland }, NullLogger.Instance, () => { var hook = new QueuedGlobalHook(provider); hook.KeyPressed += (_, _) => key(); @@ -365,6 +407,11 @@ internal sealed class NullPlatformServicesForHooks : IPlatformServices public bool PressCarriesPosition { get; init; } = true; public bool FileManager { get; set; } = true; public string? Process { get; set; } = "explorer"; + + /// What reports (null: the hook's mask decides, as on Windows and Linux). + public ModifierKeys? Modifiers { get; set; } + + public ModifierKeys? CurrentModifiers() => Modifiers; public bool IsFileManagerAt(int x, int y) => FileManager; public string? ProcessNameAt(int x, int y) => Process; public IReadOnlyList GetFileManagerSelection() => []; @@ -377,5 +424,9 @@ public void SetContextMenu(bool enabled, string executablePath, string label) { public void RevealInFileManager(string path) { } public void OpenFile(string path) { } public void RaiseTopmost(nint windowHandle) { } + public int Remembered { get; private set; } + public int Returned { get; private set; } + public void RememberFrontApp() => Remembered++; + public void ReturnFocus() => Returned++; } }