Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 5 additions & 3 deletions src/Filee.App/Controls/DonutMenu.Flip.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -85,8 +87,8 @@ private sealed record OldGroup(int Index, IReadOnlyList<DonutItem> Children, dou
public bool FlipWithWheel { get => GetValue(FlipWithWheelProperty); set => SetValue(FlipWithWheelProperty, value); }

/// <summary>
/// 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).
/// </summary>
public bool FlipKeyFromHook { get; set; }

Expand Down
60 changes: 56 additions & 4 deletions src/Filee.App/Services/RadialController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ public void Close(bool animate = true)
_window.DonutControl.LeaveProgressMode();
}
_visible = false;
ReturnKeyboard();
WatchFlipKey();
_grace.Cancel();
ReplaceWeb(null);
Expand Down Expand Up @@ -261,6 +262,7 @@ private RadialWindow EnsureWindow()
return;
viewModel.Load(files, clickMode: false);
donut.PlayOpenAnimation();
TakeKeyboard(window);
WatchFlipKey();
};
window.Dropped += (hit, dropped) =>
Expand All @@ -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)
Expand All @@ -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 += (_, _) =>
Expand Down Expand Up @@ -352,6 +362,7 @@ private void Convert(int index, int sub, IReadOnlyList<string> 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);
Expand All @@ -363,6 +374,7 @@ private void Convert(int index, int sub, IReadOnlyList<string> 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;
Expand All @@ -372,6 +384,7 @@ private void Convert(int index, int sub, IReadOnlyList<string> 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;
Expand All @@ -387,6 +400,7 @@ private void Convert(int index, int sub, IReadOnlyList<string> 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<string> files, Preset preset, WebDrop? web, bool toClipboard)
Expand Down Expand Up @@ -540,6 +554,7 @@ private void DetachProgress()
private void HideWindow()
{
_visible = false;
ReturnKeyboard();
WatchFlipKey();
_window?.DonutControl.CollapseGroup();
_window?.Hide();
Expand Down Expand Up @@ -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;
}

/// <summary>A drag-mode donut shows files and nothing was dropped yet: the hook's flip key and wheel flip it.</summary>
private bool FlipsFromHook => _visible && !_dropped && !viewModel.IsClickMode && viewModel.HasFiles;

/// <summary>The hook's flip key flips the donut: in drag mode, while its window doesn't have the keyboard.</summary>
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;

/// <summary>
/// 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 (<see cref="ReturnKeyboard"/>), and stays with
/// Filee when the drop opens a window of its own: a tool, the merge order, the save dialog (<see cref="KeepKeyboard"/>).
/// Not on Windows, where a window taking the keyboard during a drag breaks Explorer's drag.
/// </summary>
private void TakeKeyboard(RadialWindow window)
{
if (!OperatingSystem.IsMacOS() || _tookKeyboard || window.IsActive)
return;
platform.RememberFrontApp();
_tookKeyboard = true;
window.Activate();
}

/// <summary>The drop opens a window of Filee's own: the keyboard stays with Filee for it, not given back on close.</summary>
private void KeepKeyboard() => _tookKeyboard = false;

/// <summary>Gives the keyboard back to the app the donut took it from, if the donut still has it.</summary>
private void ReturnKeyboard()
{
if (!_tookKeyboard)
return;
_tookKeyboard = false;
platform.ReturnFocus();
}

/// <summary>
/// Top-left corner of a <paramref name="sidePx"/> square window centred on <paramref name="cursor"/>, moved into
/// <paramref name="area"/> when that would cross its edge. With group slices the window holds the ring they fan out
Expand Down
17 changes: 13 additions & 4 deletions src/Filee.App/Services/Triggers/TriggerService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
{
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -380,6 +380,15 @@ internal bool InScope(TriggerGesture gesture, int x, int y)
_ => TriggerMouseButton.None,
};

/// <summary>
/// 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.
/// </summary>
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;
Expand Down
20 changes: 20 additions & 0 deletions src/Filee.Core/Platform/IPlatformServices.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using Filee.Core.Settings;

namespace Filee.Core.Platform;

/// <summary>Operating-system specific services.</summary>
Expand Down Expand Up @@ -36,6 +38,12 @@ public interface IPlatformServices
/// </summary>
ForegroundWindowInfo? GetForegroundWindow() => null;

/// <summary>
/// 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).
/// </summary>
ModifierKeys? CurrentModifiers() => null;

/// <summary>
/// Names of the apps that have a window open now, as <see cref="ProcessNameAt"/> reports them, sorted, without
/// Filee. Windows reports a main window per process; macOS and Linux list the windows on screen.
Expand Down Expand Up @@ -154,6 +162,18 @@ void OpenWith(string path) { }

/// <summary>Restores temporary native window configuration after a raised window hides or before it closes.</summary>
void RestoreWindow(nint windowHandle) { }

/// <summary>
/// Remembers the app in front now, so <see cref="ReturnFocus"/> 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.
/// </summary>
void RememberFrontApp() { }

/// <summary>
/// Gives the keyboard back to the app <see cref="RememberFrontApp"/> remembered, if this app still has it (the user
/// didn't click elsewhere meanwhile) and that app still runs; forgets it either way.
/// </summary>
void ReturnFocus() { }
}

/// <summary>
Expand Down
24 changes: 24 additions & 0 deletions src/Filee.Platform.MacOS/MacOSPlatformServices.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using System.Text;
using System.Text.Json;
using Filee.Core.Platform;
using Filee.Core.Settings;

namespace Filee.Platform.MacOS;

Expand Down Expand Up @@ -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<string> RunningAppNames() => IPlatformServices.AppNames(NativeMethods.WindowOwnerNames().ToList());

public IReadOnlyList<string> GetFileManagerSelection()
Expand Down Expand Up @@ -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);
}

/// <summary>
/// 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).
Expand Down
34 changes: 34 additions & 0 deletions src/Filee.Platform.MacOS/NativeMethods.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using Filee.Core.Settings;

namespace Filee.Platform.MacOS;

Expand All @@ -22,6 +23,18 @@ internal static class NativeMethods
private static nint CoreFoundationConstant(string name) =>
Marshal.ReadIntPtr(NativeLibrary.GetExport(NativeLibrary.Load(CoreFoundation), name));

/// <summary>The modifier keys held now (kCGEventSourceStateCombinedSessionState: every keyboard, including posted keys).</summary>
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;
}

/// <summary>Process names of the app windows on screen (layer 0), front to back.</summary>
internal static IEnumerable<string> WindowOwnerNames()
{
Expand Down Expand Up @@ -113,6 +126,24 @@ internal static bool IsFinderFrontmost()
return Marshal.PtrToStringUTF8(Send(bundle, sel_registerName("UTF8String"))) == "com.apple.finder";
}

/// <summary>The process id of the app in front (NSWorkspace.frontmostApplication), 0 if none.</summary>
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"));
}

/// <summary>Brings the app with process id <paramref name="pid"/> to the front, if it still runs.</summary>
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();
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading