diff --git a/Flow.Launcher.Core/Plugin/PluginManager.cs b/Flow.Launcher.Core/Plugin/PluginManager.cs index aae8dd76419..1648d637743 100644 --- a/Flow.Launcher.Core/Plugin/PluginManager.cs +++ b/Flow.Launcher.Core/Plugin/PluginManager.cs @@ -274,13 +274,16 @@ public static async Task InitializePluginsAsync() } } - public static ICollection ValidPluginsForQuery(Query query) + public static ICollection ValidPluginsForQuery(Query query, bool quickSwitch) { if (query is null) return Array.Empty(); if (!NonGlobalPlugins.TryGetValue(query.ActionKeyword, out var plugin)) - return GlobalPlugins; + return quickSwitch ? GlobalPlugins.Where(p => p.Plugin is IAsyncQuickSwitch).ToList() : GlobalPlugins; + + if (quickSwitch && plugin.Plugin is not IAsyncQuickSwitch) + return Array.Empty(); return new List { @@ -366,6 +369,36 @@ public static async Task> QueryHomeForPluginAsync(PluginPair pair, } return results; } + + public static async Task> QueryQuickSwitchForPluginAsync(PluginPair pair, Query query, CancellationToken token) + { + var results = new List(); + var metadata = pair.Metadata; + + try + { + var milliseconds = await API.StopwatchLogDebugAsync(ClassName, $"Cost for {metadata.Name}", + async () => results = await ((IAsyncQuickSwitch)pair.Plugin).QueryQuickSwitchAsync(query, token).ConfigureAwait(false)); + + token.ThrowIfCancellationRequested(); + if (results == null) + return null; + UpdatePluginMetadata(results, metadata, query); + + token.ThrowIfCancellationRequested(); + } + catch (OperationCanceledException) + { + // null will be fine since the results will only be added into queue if the token hasn't been cancelled + return null; + } + catch (Exception e) + { + API.LogException(ClassName, $"Failed to query quick switch for plugin: {metadata.Name}", e); + return null; + } + return results; + } public static void UpdatePluginMetadata(IReadOnlyList results, PluginMetadata metadata, Query query) { diff --git a/Flow.Launcher.Infrastructure/Flow.Launcher.Infrastructure.csproj b/Flow.Launcher.Infrastructure/Flow.Launcher.Infrastructure.csproj index 31547200b23..dd2d71e28ec 100644 --- a/Flow.Launcher.Infrastructure/Flow.Launcher.Infrastructure.csproj +++ b/Flow.Launcher.Infrastructure/Flow.Launcher.Infrastructure.csproj @@ -1,4 +1,4 @@ - + net7.0-windows @@ -59,12 +59,14 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + all runtime; build; native; contentfiles; analyzers; buildtransitive + all @@ -75,6 +77,5 @@ - \ No newline at end of file diff --git a/Flow.Launcher.Infrastructure/NativeMethods.txt b/Flow.Launcher.Infrastructure/NativeMethods.txt index 0e50420b0e0..b20d2950632 100644 --- a/Flow.Launcher.Infrastructure/NativeMethods.txt +++ b/Flow.Launcher.Infrastructure/NativeMethods.txt @@ -57,3 +57,25 @@ LOCALE_TRANSIENT_KEYBOARD1 LOCALE_TRANSIENT_KEYBOARD2 LOCALE_TRANSIENT_KEYBOARD3 LOCALE_TRANSIENT_KEYBOARD4 + +SetWinEventHook +UnhookWinEvent +SendMessage +EVENT_SYSTEM_FOREGROUND +WINEVENT_OUTOFCONTEXT +WM_SETTEXT +IShellFolderViewDual2 +CoCreateInstance +CLSCTX +IShellWindows +IWebBrowser2 +EVENT_OBJECT_DESTROY +EVENT_OBJECT_LOCATIONCHANGE +EVENT_SYSTEM_MOVESIZESTART +EVENT_SYSTEM_MOVESIZEEND +GetDlgItem +PostMessage +BM_CLICK +WM_GETTEXT +OpenProcess +QueryFullProcessImageName diff --git a/Flow.Launcher.Infrastructure/PInvokeExtensions.cs b/Flow.Launcher.Infrastructure/PInvokeExtensions.cs index 1a72ab7a66a..23e4c0ce251 100644 --- a/Flow.Launcher.Infrastructure/PInvokeExtensions.cs +++ b/Flow.Launcher.Infrastructure/PInvokeExtensions.cs @@ -4,14 +4,16 @@ namespace Windows.Win32; -// Edited from: https://github.com/files-community/Files +/// +/// Edited from: https://github.com/files-community/Files +/// internal static partial class PInvoke { [DllImport("User32", EntryPoint = "SetWindowLongW", ExactSpelling = true)] - static extern int _SetWindowLong(HWND hWnd, int nIndex, int dwNewLong); + private static extern int _SetWindowLong(HWND hWnd, int nIndex, int dwNewLong); [DllImport("User32", EntryPoint = "SetWindowLongPtrW", ExactSpelling = true)] - static extern nint _SetWindowLongPtr(HWND hWnd, int nIndex, nint dwNewLong); + private static extern nint _SetWindowLongPtr(HWND hWnd, int nIndex, nint dwNewLong); // NOTE: // CsWin32 doesn't generate SetWindowLong on other than x86 and vice versa. diff --git a/Flow.Launcher.Infrastructure/QuickSwitch/Interface/IQuickSwitchDialog.cs b/Flow.Launcher.Infrastructure/QuickSwitch/Interface/IQuickSwitchDialog.cs new file mode 100644 index 00000000000..d2d08dbf510 --- /dev/null +++ b/Flow.Launcher.Infrastructure/QuickSwitch/Interface/IQuickSwitchDialog.cs @@ -0,0 +1,20 @@ +using System; +using Windows.Win32.Foundation; + +namespace Flow.Launcher.Infrastructure.QuickSwitch.Interface +{ + /// + /// Interface for handling File Dialog instances in QuickSwitch. + /// + /// + /// Add models which implement IQuickSwitchDialog in folder QuickSwitch/Models. + /// E.g. Models.WindowsDialog. + /// Then add instances in QuickSwitch._quickSwitchDialogs. + /// + internal interface IQuickSwitchDialog : IDisposable + { + IQuickSwitchDialogWindow DialogWindow { get; } + + bool CheckDialogWindow(HWND hwnd); + } +} diff --git a/Flow.Launcher.Infrastructure/QuickSwitch/Interface/IQuickSwitchDialogWindow.cs b/Flow.Launcher.Infrastructure/QuickSwitch/Interface/IQuickSwitchDialogWindow.cs new file mode 100644 index 00000000000..8834e27f78d --- /dev/null +++ b/Flow.Launcher.Infrastructure/QuickSwitch/Interface/IQuickSwitchDialogWindow.cs @@ -0,0 +1,12 @@ +using System; +using Windows.Win32.Foundation; + +namespace Flow.Launcher.Infrastructure.QuickSwitch.Interface +{ + internal interface IQuickSwitchDialogWindow : IDisposable + { + HWND Handle { get; } + + IQuickSwitchDialogWindowTab GetCurrentTab(); + } +} diff --git a/Flow.Launcher.Infrastructure/QuickSwitch/Interface/IQuickSwitchDialogWindowTab.cs b/Flow.Launcher.Infrastructure/QuickSwitch/Interface/IQuickSwitchDialogWindowTab.cs new file mode 100644 index 00000000000..d01059a7c76 --- /dev/null +++ b/Flow.Launcher.Infrastructure/QuickSwitch/Interface/IQuickSwitchDialogWindowTab.cs @@ -0,0 +1,20 @@ +using System; +using Windows.Win32.Foundation; + +namespace Flow.Launcher.Infrastructure.QuickSwitch.Interface +{ + internal interface IQuickSwitchDialogWindowTab : IDisposable + { + HWND Handle { get; } + + string GetCurrentFolder(); + + string GetCurrentFile(); + + bool JumpFolder(string path, bool auto); + + bool JumpFile(string path); + + bool Open(); + } +} diff --git a/Flow.Launcher.Infrastructure/QuickSwitch/Interface/IQuickSwitchExplorer.cs b/Flow.Launcher.Infrastructure/QuickSwitch/Interface/IQuickSwitchExplorer.cs new file mode 100644 index 00000000000..9bf3d95911f --- /dev/null +++ b/Flow.Launcher.Infrastructure/QuickSwitch/Interface/IQuickSwitchExplorer.cs @@ -0,0 +1,22 @@ +using System; +using Windows.Win32.Foundation; + +namespace Flow.Launcher.Infrastructure.QuickSwitch.Interface +{ + /// + /// Interface for handling Windows Explorer instances in QuickSwitch. + /// + /// + /// Add models which implement IQuickSwitchExplorer in folder QuickSwitch/Models. + /// E.g. Models.WindowsExplorer. + /// Then add instances in QuickSwitch._quickSwitchExplorers. + /// + internal interface IQuickSwitchExplorer : IDisposable + { + bool CheckExplorerWindow(HWND foreground); + + void RemoveExplorerWindow(); + + string GetExplorerPath(); + } +} diff --git a/Flow.Launcher.Infrastructure/QuickSwitch/Models/WindowsDialog.cs b/Flow.Launcher.Infrastructure/QuickSwitch/Models/WindowsDialog.cs new file mode 100644 index 00000000000..ed0d5a56265 --- /dev/null +++ b/Flow.Launcher.Infrastructure/QuickSwitch/Models/WindowsDialog.cs @@ -0,0 +1,355 @@ +using System; +using System.Threading; +using Flow.Launcher.Infrastructure.Logger; +using Flow.Launcher.Infrastructure.QuickSwitch.Interface; +using Windows.Win32; +using Windows.Win32.Foundation; +using Windows.Win32.UI.WindowsAndMessaging; +using WindowsInput; +using WindowsInput.Native; + +namespace Flow.Launcher.Infrastructure.QuickSwitch.Models +{ + /// + /// Class for handling Windows File Dialog instances in QuickSwitch. + /// + internal class WindowsDialog : IQuickSwitchDialog + { + public IQuickSwitchDialogWindow DialogWindow { get; private set; } + + private const string WindowsDialogClassName = "#32770"; + + public bool CheckDialogWindow(HWND hwnd) + { + // Has it been checked? + if (DialogWindow != null && DialogWindow.Handle == hwnd) + { + return true; + } + + // Is it a Win32 dialog box? + if (GetClassName(hwnd) == WindowsDialogClassName) + { + // Is it a windows file dialog? + var dialogType = GetFileDialogType(hwnd); + if (dialogType != DialogType.Others) + { + DialogWindow = new WindowsDialogWindow(hwnd, dialogType); + + return true; + } + } + return false; + } + + public void Dispose() + { + DialogWindow?.Dispose(); + DialogWindow = null; + } + + #region Help Methods + + private static unsafe string GetClassName(HWND handle) + { + fixed (char* buf = new char[256]) + { + return PInvoke.GetClassName(handle, buf, 256) switch + { + 0 => string.Empty, + _ => new string(buf), + }; + } + } + + private static DialogType GetFileDialogType(HWND handle) + { + // Is it a Windows Open file dialog? + var fileEditor = PInvoke.GetDlgItem(handle, 0x047C); + if (fileEditor != HWND.Null && GetClassName(fileEditor) == "ComboBoxEx32") return DialogType.Open; + + // Is it a Windows Save or Save As file dialog? + fileEditor = PInvoke.GetDlgItem(handle, 0x0000); + if (fileEditor != HWND.Null && GetClassName(fileEditor) == "DUIViewWndClassName") return DialogType.SaveOrSaveAs; + + return DialogType.Others; + } + + #endregion + } + + internal class WindowsDialogWindow : IQuickSwitchDialogWindow + { + public HWND Handle { get; private set; } = HWND.Null; + + // After jumping folder, file editor handle of Save / SaveAs file dialogs cannot be found anymore + // So we need to cache the current tab and use the original handle + private IQuickSwitchDialogWindowTab _currentTab { get; set; } = null; + + private readonly DialogType _dialogType; + + public WindowsDialogWindow(HWND handle, DialogType dialogType) + { + Handle = handle; + _dialogType = dialogType; + } + + public IQuickSwitchDialogWindowTab GetCurrentTab() + { + return _currentTab ??= new WindowsDialogTab(Handle, _dialogType); + } + + public void Dispose() + { + Handle = HWND.Null; + } + } + + internal class WindowsDialogTab : IQuickSwitchDialogWindowTab + { + #region Public Properties + + public HWND Handle { get; private set; } = HWND.Null; + + #endregion + + #region Private Fields + + private static readonly string ClassName = nameof(WindowsDialogTab); + + private static readonly InputSimulator _inputSimulator = new(); + + private readonly DialogType _dialogType; + + private bool _legacy { get; set; } = false; + private HWND _pathControl { get; set; } = HWND.Null; + private HWND _pathEditor { get; set; } = HWND.Null; + private HWND _fileEditor { get; set; } = HWND.Null; + private HWND _openButton { get; set; } = HWND.Null; + + #endregion + + #region Constructor + + public WindowsDialogTab(HWND handle, DialogType dialogType) + { + Handle = handle; + _dialogType = dialogType; + Log.Debug(ClassName, $"File dialog type: {dialogType}"); + } + + #endregion + + #region Public Methods + + public string GetCurrentFolder() + { + if (_pathEditor.IsNull && !GetPathControlEditor()) return string.Empty; + return GetWindowText(_pathEditor); + } + + public string GetCurrentFile() + { + if (_fileEditor.IsNull && !GetFileEditor()) return string.Empty; + return GetWindowText(_fileEditor); + } + + public bool JumpFolder(string path, bool auto) + { + if (auto) + { + // Use legacy jump folder method for auto quick switch because file editor is default value. + // After setting path using file editor, we do not need to revert its value. + return JumpFolderWithFileEditor(path, false); + } + + // Alt-D or Ctrl-L to focus on the path input box + // "ComboBoxEx32" is not visible when the path editor is not with the keyboard focus + _inputSimulator.Keyboard.ModifiedKeyStroke(VirtualKeyCode.LMENU, VirtualKeyCode.VK_D); + // _inputSimulator.Keyboard.ModifiedKeyStroke(VirtualKeyCode.LCONTROL, VirtualKeyCode.VK_L); + + if (_pathControl.IsNull && !GetPathControlEditor()) + { + // https://github.com/idkidknow/Flow.Launcher.Plugin.DirQuickJump/issues/1 + // The dialog is a legacy one, so we can only edit file editor directly. + Log.Debug(ClassName, "Legacy dialog, using legacy jump folder method"); + return JumpFolderWithFileEditor(path, true); + } + + var timeOut = !SpinWait.SpinUntil(() => + { + var style = PInvoke.GetWindowLong(_pathControl, WINDOW_LONG_PTR_INDEX.GWL_STYLE); + return (style & (int)WINDOW_STYLE.WS_VISIBLE) != 0; + }, 1000); + if (timeOut) + { + // Path control is not visible, so we can only edit file editor directly. + Log.Debug(ClassName, "Path control is not visible, using legacy jump folder method"); + return JumpFolderWithFileEditor(path, true); + } + + if (_pathEditor.IsNull) + { + // Path editor cannot be found, so we can only edit file editor directly. + Log.Debug(ClassName, "Path editor cannot be found, using legacy jump folder method"); + return JumpFolderWithFileEditor(path, true); + } + SetWindowText(_pathEditor, path); + + _inputSimulator.Keyboard.KeyPress(VirtualKeyCode.RETURN); + + return true; + } + + public bool JumpFile(string path) + { + if (_fileEditor.IsNull && !GetFileEditor()) return false; + SetWindowText(_fileEditor, path); + + return true; + } + + public bool Open() + { + if (_openButton.IsNull && !GetOpenButton()) return false; + PInvoke.PostMessage(_openButton, PInvoke.BM_CLICK, 0, 0); + + return true; + } + + public void Dispose() + { + Handle = HWND.Null; + } + + #endregion + + #region Helper Methods + + #region Get Handles + + private bool GetPathControlEditor() + { + // Get the handle of the path editor + // Must use PInvoke.FindWindowEx because PInvoke.GetDlgItem(Handle, 0x0000) will get another control + _pathControl = PInvoke.FindWindowEx(Handle, HWND.Null, "WorkerW", null); // 0x0000 + _pathControl = PInvoke.FindWindowEx(_pathControl, HWND.Null, "ReBarWindow32", null); // 0xA005 + _pathControl = PInvoke.FindWindowEx(_pathControl, HWND.Null, "Address Band Root", null); // 0xA205 + _pathControl = PInvoke.FindWindowEx(_pathControl, HWND.Null, "msctls_progress32", null); // 0x0000 + _pathControl = PInvoke.FindWindowEx(_pathControl, HWND.Null, "ComboBoxEx32", null); // 0xA205 + if (_pathControl == HWND.Null) + { + _pathEditor = HWND.Null; + _legacy = true; + Log.Info(ClassName, "Legacy dialog"); + } + else + { + _pathEditor = PInvoke.GetDlgItem(_pathControl, 0xA205); // ComboBox + _pathEditor = PInvoke.GetDlgItem(_pathEditor, 0xA205); // Edit + if (_pathEditor == HWND.Null) + { + _legacy = true; + Log.Error(ClassName, "Failed to find path editor handle"); + } + } + + return !_legacy; + } + + private bool GetFileEditor() + { + if (_dialogType == DialogType.Open) + { + // Get the handle of the file name editor of Open file dialog + _fileEditor = PInvoke.GetDlgItem(Handle, 0x047C); // ComboBoxEx32 + _fileEditor = PInvoke.GetDlgItem(_fileEditor, 0x047C); // ComboBox + _fileEditor = PInvoke.GetDlgItem(_fileEditor, 0x047C); // Edit + } + else + { + // Get the handle of the file name editor of Save / SaveAs file dialog + _fileEditor = PInvoke.GetDlgItem(Handle, 0x0000); // DUIViewWndClassName + _fileEditor = PInvoke.GetDlgItem(_fileEditor, 0x0000); // DirectUIHWND + _fileEditor = PInvoke.GetDlgItem(_fileEditor, 0x0000); // FloatNotifySink + _fileEditor = PInvoke.GetDlgItem(_fileEditor, 0x0000); // ComboBox + _fileEditor = PInvoke.GetDlgItem(_fileEditor, 0x03E9); // Edit + } + + if (_fileEditor == HWND.Null) + { + Log.Error(ClassName, "Failed to find file name editor handle"); + return false; + } + + return true; + } + + private bool GetOpenButton() + { + // Get the handle of the open button + _openButton = PInvoke.GetDlgItem(Handle, 0x0001); // Open/Save/SaveAs Button + if (_openButton == HWND.Null) + { + Log.Error(ClassName, "Failed to find open button handle"); + return false; + } + + return true; + } + + #endregion + + #region Windows Text + + private static unsafe string GetWindowText(HWND handle) + { + int length; + Span buffer = stackalloc char[1000]; + fixed (char* pBuffer = buffer) + { + // If the control has no title bar or text, or if the control handle is invalid, the return value is zero. + length = (int)PInvoke.SendMessage(handle, PInvoke.WM_GETTEXT, 1000, (nint)pBuffer); + } + + return buffer[..length].ToString(); + } + + private static unsafe nint SetWindowText(HWND handle, string text) + { + fixed (char* textPtr = text + '\0') + { + return PInvoke.SendMessage(handle, PInvoke.WM_SETTEXT, 0, (nint)textPtr).Value; + } + } + + #endregion + + #region Legacy Jump Folder + + private bool JumpFolderWithFileEditor(string path, bool resetFocus) + { + // For Save / Save As dialog, the default value in file editor is not null and it can cause strange behaviors. + if (resetFocus && _dialogType == DialogType.SaveOrSaveAs) return false; + + if (_fileEditor.IsNull && !GetFileEditor()) return false; + SetWindowText(_fileEditor, path); + + if (_openButton.IsNull && !GetOpenButton()) return false; + PInvoke.SendMessage(_openButton, PInvoke.BM_CLICK, 0, 0); + + return true; + } + + #endregion + + #endregion + } + + internal enum DialogType + { + Others, + Open, + SaveOrSaveAs + } +} diff --git a/Flow.Launcher.Infrastructure/QuickSwitch/Models/WindowsExplorer.cs b/Flow.Launcher.Infrastructure/QuickSwitch/Models/WindowsExplorer.cs new file mode 100644 index 00000000000..b85a95a3caa --- /dev/null +++ b/Flow.Launcher.Infrastructure/QuickSwitch/Models/WindowsExplorer.cs @@ -0,0 +1,167 @@ +using System; +using System.Runtime.InteropServices; +using Flow.Launcher.Infrastructure.QuickSwitch.Interface; +using Windows.Win32; +using Windows.Win32.Foundation; +using Windows.Win32.System.Com; +using Windows.Win32.UI.Shell; + +namespace Flow.Launcher.Infrastructure.QuickSwitch.Models +{ + /// + /// Class for handling Windows Explorer instances in QuickSwitch. + /// + internal class WindowsExplorer : IQuickSwitchExplorer + { + private static readonly string ClassName = nameof(WindowsExplorer); + + private static IWebBrowser2 _lastExplorerView = null; + private static readonly object _lastExplorerViewLock = new(); + + public bool CheckExplorerWindow(HWND foreground) + { + var isExplorer = false; + // Is it from Explorer? + var processName = Win32Helper.GetProcessNameFromHwnd(foreground); + if (processName.ToLower() == "explorer.exe") + { + EnumerateShellWindows((shellWindow) => + { + try + { + if (shellWindow is not IWebBrowser2 explorer) return true; + + if (explorer.HWND != foreground.Value) return true; + + lock (_lastExplorerViewLock) + { + _lastExplorerView = explorer; + } + isExplorer = true; + return false; + } + catch (COMException) + { + // Ignored + } + + return true; + }); + } + return isExplorer; + } + + private static unsafe void EnumerateShellWindows(Func action) + { + // Create an instance of ShellWindows + var clsidShellWindows = new Guid("9BA05972-F6A8-11CF-A442-00A0C90A8F39"); // ShellWindowsClass + var iidIShellWindows = typeof(IShellWindows).GUID; // IShellWindows + + var result = PInvoke.CoCreateInstance( + &clsidShellWindows, + null, + CLSCTX.CLSCTX_ALL, + &iidIShellWindows, + out var shellWindowsObj); + + if (result.Failed) return; + + var shellWindows = (IShellWindows)shellWindowsObj; + + // Enumerate the shell windows + var count = shellWindows.Count; + for (var i = 0; i < count; i++) + { + if (!action(shellWindows.Item(i))) + { + return; + } + } + } + + public string GetExplorerPath() + { + if (_lastExplorerView == null) return null; + + object document = null; + try + { + lock (_lastExplorerViewLock) + { + if (_lastExplorerView != null) + { + // Use dynamic here because using IWebBrower2.Document can cause exception here: + // System.Runtime.InteropServices.InvalidOleVariantTypeException: 'Specified OLE variant is invalid.' + dynamic explorerView = _lastExplorerView; + document = explorerView.Document; + } + } + } + catch (COMException) + { + return null; + } + + if (document is not IShellFolderViewDual2 folderView) + { + return null; + } + + string path; + try + { + // CSWin32 Folder does not have Self, so we need to use dynamic type here + // Use dynamic to bypass static typing + dynamic folder = folderView.Folder; + + // Access the Self property via dynamic binding + dynamic folderItem = folder.Self; + + // Check if the item is part of the file system + if (folderItem != null && folderItem.IsFileSystem) + { + path = folderItem.Path; + } + else + { + // Handle non-file system paths (e.g., virtual folders) + path = string.Empty; + } + } + catch + { + return null; + } + + return path; + } + + public void RemoveExplorerWindow() + { + lock (_lastExplorerViewLock) + { + _lastExplorerView = null; + } + } + + public void Dispose() + { + // Release ComObjects + try + { + lock (_lastExplorerViewLock) + { + if (_lastExplorerView != null) + { + Marshal.ReleaseComObject(_lastExplorerView); + _lastExplorerView = null; + } + } + } + catch (COMException) + { + _lastExplorerView = null; + } + } + } +} diff --git a/Flow.Launcher.Infrastructure/QuickSwitch/QuickSwitch.cs b/Flow.Launcher.Infrastructure/QuickSwitch/QuickSwitch.cs new file mode 100644 index 00000000000..5fc818a2a61 --- /dev/null +++ b/Flow.Launcher.Infrastructure/QuickSwitch/QuickSwitch.cs @@ -0,0 +1,839 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Threading; +using CommunityToolkit.Mvvm.DependencyInjection; +using Flow.Launcher.Infrastructure.Logger; +using Flow.Launcher.Infrastructure.QuickSwitch.Interface; +using Flow.Launcher.Infrastructure.QuickSwitch.Models; +using Flow.Launcher.Infrastructure.UserSettings; +using NHotkey; +using Windows.Win32; +using Windows.Win32.Foundation; +using Windows.Win32.UI.Accessibility; + +namespace Flow.Launcher.Infrastructure.QuickSwitch +{ + public static class QuickSwitch + { + #region Public Properties + + public static Func ShowQuickSwitchWindowAsync { get; set; } = null; + + public static Action UpdateQuickSwitchWindow { get; set; } = null; + + public static Action ResetQuickSwitchWindow { get; set; } = null; + + public static Action HideQuickSwitchWindow { get; set; } = null; + + public static QuickSwitchWindowPositions QuickSwitchWindowPosition { get; private set; } + + #endregion + + #region Private Fields + + private static readonly string ClassName = nameof(QuickSwitch); + + private static readonly Settings _settings = Ioc.Default.GetRequiredService(); + + private static HWND _mainWindowHandle = HWND.Null; + + private static readonly List _quickSwitchExplorers = new() + { + new WindowsExplorer() + }; + + private static IQuickSwitchExplorer _lastExplorer = null; + private static readonly object _lastExplorerLock = new(); + + private static readonly List _quickSwitchDialogs = new() + { + new WindowsDialog() + }; + + private static IQuickSwitchDialogWindow _dialogWindow = null; + private static readonly object _dialogWindowLock = new(); + + private static HWINEVENTHOOK _foregroundChangeHook = HWINEVENTHOOK.Null; + private static HWINEVENTHOOK _locationChangeHook = HWINEVENTHOOK.Null; + private static HWINEVENTHOOK _destroyChangeHook = HWINEVENTHOOK.Null; + + private static readonly WINEVENTPROC _fgProc = ForegroundChangeCallback; + private static readonly WINEVENTPROC _locProc = LocationChangeCallback; + private static readonly WINEVENTPROC _desProc = DestroyChangeCallback; + + private static DispatcherTimer _dragMoveTimer = null; + + // A list of all file dialog windows that are auto switched already + private static readonly List _autoSwitchedDialogs = new(); + private static readonly object _autoSwitchedDialogsLock = new(); + + private static HWINEVENTHOOK _moveSizeHook = HWINEVENTHOOK.Null; + private static readonly WINEVENTPROC _moveProc = MoveSizeCallBack; + + private static readonly SemaphoreSlim _foregroundChangeLock = new(1, 1); + private static readonly SemaphoreSlim _navigationLock = new(1, 1); + + private static bool _initialized = false; + private static bool _enabled = false; + + #endregion + + #region Initialize & Setup + + public static void InitializeQuickSwitch() + { + if (_initialized) return; + + // Initialize main window handle + _mainWindowHandle = Win32Helper.GetMainWindowHandle(); + + // Initialize timer + _dragMoveTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(10) }; + _dragMoveTimer.Tick += (s, e) => InvokeUpdateQuickSwitchWindow(); + + // Initialize quick switch window position + QuickSwitchWindowPosition = _settings.QuickSwitchWindowPosition; + + _initialized = true; + } + + public static void SetupQuickSwitch(bool enabled) + { + if (enabled == _enabled) return; + + if (enabled) + { + // Check if there are explorer windows and get the topmost one + try + { + if (RefreshLastExplorer()) + { + Log.Debug(ClassName, $"Explorer window found"); + } + } + catch (System.Exception) + { + // Ignored + } + + // Unhook events + if (!_foregroundChangeHook.IsNull) + { + PInvoke.UnhookWinEvent(_foregroundChangeHook); + _foregroundChangeHook = HWINEVENTHOOK.Null; + } + if (!_locationChangeHook.IsNull) + { + PInvoke.UnhookWinEvent(_locationChangeHook); + _locationChangeHook = HWINEVENTHOOK.Null; + } + if (!_destroyChangeHook.IsNull) + { + PInvoke.UnhookWinEvent(_destroyChangeHook); + _destroyChangeHook = HWINEVENTHOOK.Null; + } + + // Hook events + _foregroundChangeHook = PInvoke.SetWinEventHook( + PInvoke.EVENT_SYSTEM_FOREGROUND, + PInvoke.EVENT_SYSTEM_FOREGROUND, + PInvoke.GetModuleHandle((PCWSTR)null), + _fgProc, + 0, + 0, + PInvoke.WINEVENT_OUTOFCONTEXT); + _locationChangeHook = PInvoke.SetWinEventHook( + PInvoke.EVENT_OBJECT_LOCATIONCHANGE, + PInvoke.EVENT_OBJECT_LOCATIONCHANGE, + PInvoke.GetModuleHandle((PCWSTR)null), + _locProc, + 0, + 0, + PInvoke.WINEVENT_OUTOFCONTEXT); + _destroyChangeHook = PInvoke.SetWinEventHook( + PInvoke.EVENT_OBJECT_DESTROY, + PInvoke.EVENT_OBJECT_DESTROY, + PInvoke.GetModuleHandle((PCWSTR)null), + _desProc, + 0, + 0, + PInvoke.WINEVENT_OUTOFCONTEXT); + + if (_foregroundChangeHook.IsNull || + _locationChangeHook.IsNull || + _destroyChangeHook.IsNull) + { + Log.Error(ClassName, "Failed to enable QuickSwitch"); + return; + } + } + else + { + // Remove last explorer + foreach (var explorer in _quickSwitchExplorers) + { + explorer.RemoveExplorerWindow(); + } + + // Remove dialog window handle + var dialogWindowExists = false; + lock (_dialogWindowLock) + { + if (_dialogWindow != null) + { + _dialogWindow.Dispose(); + _dialogWindow = null; + dialogWindowExists = true; + } + } + + // Remove auto switched dialogs + lock (_autoSwitchedDialogsLock) + { + _autoSwitchedDialogs.Clear(); + } + + // Unhook events + if (!_foregroundChangeHook.IsNull) + { + PInvoke.UnhookWinEvent(_foregroundChangeHook); + _foregroundChangeHook = HWINEVENTHOOK.Null; + } + if (!_locationChangeHook.IsNull) + { + PInvoke.UnhookWinEvent(_locationChangeHook); + _locationChangeHook = HWINEVENTHOOK.Null; + } + if (!_destroyChangeHook.IsNull) + { + PInvoke.UnhookWinEvent(_destroyChangeHook); + _destroyChangeHook = HWINEVENTHOOK.Null; + } + + // Stop drag move timer + _dragMoveTimer?.Stop(); + + // Reset quick switch window + if (dialogWindowExists) + { + InvokeResetQuickSwitchWindow(); + } + } + + _enabled = enabled; + } + + private static bool RefreshLastExplorer() + { + var found = false; + + lock (_lastExplorerLock) + { + // Enum windows from the top to the bottom + PInvoke.EnumWindows((hWnd, _) => + { + foreach (var explorer in _quickSwitchExplorers) + { + if (explorer.CheckExplorerWindow(hWnd)) + { + _lastExplorer = explorer; + found = true; + return false; + } + } + + // If we reach here, it means that the window is not a file explorer + return true; + }, IntPtr.Zero); + } + + return found; + } + + #endregion + + #region Active Explorer + + public static string GetActiveExplorerPath() + { + return RefreshLastExplorer() ? _lastExplorer.GetExplorerPath() : string.Empty; + } + + #endregion + + #region Events + + #region Invoke Property Events + + private static async Task InvokeShowQuickSwitchWindowAsync(bool dialogWindowChanged) + { + // Show quick switch window + if (_settings.ShowQuickSwitchWindow) + { + // Save quick switch window position for one file dialog + if (dialogWindowChanged) + { + QuickSwitchWindowPosition = _settings.QuickSwitchWindowPosition; + } + + // Call show quick switch window + IQuickSwitchDialogWindow dialogWindow; + lock (_dialogWindowLock) + { + dialogWindow = _dialogWindow; + } + if (dialogWindow != null && ShowQuickSwitchWindowAsync != null) + { + await ShowQuickSwitchWindowAsync.Invoke(dialogWindow.Handle); + } + + // Hook move size event if quick switch window is under dialog & dialog window changed + if (QuickSwitchWindowPosition == QuickSwitchWindowPositions.UnderDialog) + { + if (dialogWindowChanged) + { + HWND dialogWindowHandle = HWND.Null; + lock (_dialogWindowLock) + { + if (_dialogWindow != null) + { + dialogWindowHandle = _dialogWindow.Handle; + } + } + + if (dialogWindowHandle == HWND.Null) return; + + if (!_moveSizeHook.IsNull) + { + PInvoke.UnhookWinEvent(_moveSizeHook); + _moveSizeHook = HWINEVENTHOOK.Null; + } + + // Call _moveProc when the window is moved or resized + SetMoveProc(dialogWindowHandle); + } + } + } + + static unsafe void SetMoveProc(HWND handle) + { + uint processId; + var threadId = PInvoke.GetWindowThreadProcessId(handle, &processId); + _moveSizeHook = PInvoke.SetWinEventHook( + PInvoke.EVENT_SYSTEM_MOVESIZESTART, + PInvoke.EVENT_SYSTEM_MOVESIZEEND, + PInvoke.GetModuleHandle((PCWSTR)null), + _moveProc, + processId, + threadId, + PInvoke.WINEVENT_OUTOFCONTEXT); + } + } + + private static void InvokeUpdateQuickSwitchWindow() + { + UpdateQuickSwitchWindow?.Invoke(); + } + + private static void InvokeResetQuickSwitchWindow() + { + lock (_dialogWindowLock) + { + _dialogWindow = null; + } + + // Reset quick switch window + ResetQuickSwitchWindow?.Invoke(); + + // Stop drag move timer + _dragMoveTimer?.Stop(); + + // Unhook move size event + if (!_moveSizeHook.IsNull) + { + PInvoke.UnhookWinEvent(_moveSizeHook); + _moveSizeHook = HWINEVENTHOOK.Null; + } + } + + private static void InvokeHideQuickSwitchWindow() + { + // Hide quick switch window + HideQuickSwitchWindow?.Invoke(); + + // Stop drag move timer + _dragMoveTimer?.Stop(); + } + + #endregion + + #region Hotkey + + public static void OnToggleHotkey(object sender, HotkeyEventArgs args) + { + _ = Task.Run(() => NavigateDialogPathAsync(PInvoke.GetForegroundWindow())); + } + + #endregion + + #region Windows Events + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD100:Avoid async void methods", Justification = "")] + private static async void ForegroundChangeCallback( + HWINEVENTHOOK hWinEventHook, + uint eventType, + HWND hwnd, + int idObject, + int idChild, + uint dwEventThread, + uint dwmsEventTime + ) + { + await _foregroundChangeLock.WaitAsync(); + try + { + // Check if it is a file dialog window + var isDialogWindow = false; + var dialogWindowChanged = false; + foreach (var dialog in _quickSwitchDialogs) + { + if (dialog.CheckDialogWindow(hwnd)) + { + lock (_dialogWindowLock) + { + dialogWindowChanged = _dialogWindow == null || _dialogWindow.Handle != hwnd; + _dialogWindow = dialog.DialogWindow; + } + + isDialogWindow = true; + break; + } + } + + // Handle window based on its type + if (isDialogWindow) + { + Log.Debug(ClassName, $"Dialog Window: {hwnd}"); + // Navigate to path + if (_settings.AutoQuickSwitch) + { + // Check if we have already switched for this dialog + bool alreadySwitched; + lock (_autoSwitchedDialogsLock) + { + alreadySwitched = _autoSwitchedDialogs.Contains(hwnd); + } + + // Just show quick switch window + if (alreadySwitched) + { + await InvokeShowQuickSwitchWindowAsync(dialogWindowChanged); + } + // Show quick switch window after navigating the path + else + { + if (!await Task.Run(() => NavigateDialogPathAsync(hwnd, true))) + { + await InvokeShowQuickSwitchWindowAsync(dialogWindowChanged); + } + } + } + else + { + await InvokeShowQuickSwitchWindowAsync(dialogWindowChanged); + } + } + // Quick switch window + else if (hwnd == _mainWindowHandle) + { + Log.Debug(ClassName, $"Main Window: {hwnd}"); + } + // Other window + else + { + Log.Debug(ClassName, $"Other Window: {hwnd}"); + var dialogWindowExist = false; + lock (_dialogWindowLock) + { + if (_dialogWindow != null) + { + dialogWindowExist = true; + } + } + if (dialogWindowExist) // Neither quick switch window nor file dialog window is foreground + { + // Hide quick switch window until the file dialog window is brought to the foreground + InvokeHideQuickSwitchWindow(); + } + + // Check if there are foreground explorer windows + try + { + lock (_lastExplorerLock) + { + foreach (var explorer in _quickSwitchExplorers) + { + if (explorer.CheckExplorerWindow(hwnd)) + { + Log.Debug(ClassName, $"Explorer window: {hwnd}"); + _lastExplorer = explorer; + break; + } + } + } + } + catch (System.Exception) + { + // Ignored + } + } + } + finally + { + _foregroundChangeLock.Release(); + } + } + + private static void LocationChangeCallback( + HWINEVENTHOOK hWinEventHook, + uint eventType, + HWND hwnd, + int idObject, + int idChild, + uint dwEventThread, + uint dwmsEventTime + ) + { + // If the dialog window is moved, update the quick switch window position + var dialogWindowExist = false; + lock (_dialogWindowLock) + { + if (_dialogWindow != null && _dialogWindow.Handle == hwnd) + { + dialogWindowExist = true; + } + } + if (dialogWindowExist) + { + InvokeUpdateQuickSwitchWindow(); + } + } + + private static void MoveSizeCallBack( + HWINEVENTHOOK hWinEventHook, + uint eventType, + HWND hwnd, + int idObject, + int idChild, + uint dwEventThread, + uint dwmsEventTime + ) + { + // If the dialog window is moved or resized, update the quick switch window position + if (_dragMoveTimer != null) + { + switch (eventType) + { + case PInvoke.EVENT_SYSTEM_MOVESIZESTART: + _dragMoveTimer.Start(); // Start dragging position + break; + case PInvoke.EVENT_SYSTEM_MOVESIZEEND: + _dragMoveTimer.Stop(); // Stop dragging + break; + } + } + } + + private static void DestroyChangeCallback( + HWINEVENTHOOK hWinEventHook, + uint eventType, + HWND hwnd, + int idObject, + int idChild, + uint dwEventThread, + uint dwmsEventTime + ) + { + // If the dialog window is destroyed, set _dialogWindowHandle to null + var dialogWindowExist = false; + lock (_dialogWindowLock) + { + if (_dialogWindow != null && _dialogWindow.Handle == hwnd) + { + Log.Debug(ClassName, $"Destory dialog: {hwnd}"); + _dialogWindow = null; + dialogWindowExist = true; + } + } + if (dialogWindowExist) + { + lock (_autoSwitchedDialogsLock) + { + _autoSwitchedDialogs.Remove(hwnd); + } + InvokeResetQuickSwitchWindow(); + } + } + + #endregion + + #endregion + + #region Path Navigation + + // Edited from: https://github.com/idkidknow/Flow.Launcher.Plugin.DirQuickJump + + public static async Task JumpToPathAsync(nint hwnd, string path) + { + // Check handle + if (hwnd == nint.Zero) return false; + + // Check path + if (!CheckPath(path, out var isFile)) return false; + + // Get dialog tab + var dialogWindowTab = GetDialogWindowTab(new(hwnd)); + if (dialogWindowTab == null) return false; + + return await JumpToPathAsync(dialogWindowTab, path, isFile, false); + } + + private static async Task NavigateDialogPathAsync(HWND hwnd, bool auto = false) + { + // Check handle + if (hwnd == HWND.Null) return false; + + // Get explorer path + string path; + lock (_lastExplorerLock) + { + path = _lastExplorer?.GetExplorerPath(); + } + if (string.IsNullOrEmpty(path)) return false; + + // Check path + if (!CheckPath(path, out var isFile)) return false; + + // Get dialog tab + var dialogWindowTab = GetDialogWindowTab(hwnd); + if (dialogWindowTab == null) return false; + + // Jump to path + return await JumpToPathAsync(dialogWindowTab, path, isFile, auto); + } + + private static bool CheckPath(string path, out bool file) + { + file = false; + // Is non-null? + if (string.IsNullOrEmpty(path)) return false; + // Is absolute? + if (!Path.IsPathRooted(path)) return false; + // Is folder? + var isFolder = Directory.Exists(path); + // Is file? + var isFile = File.Exists(path); + file = isFile; + return isFolder || isFile; + } + + private static IQuickSwitchDialogWindowTab GetDialogWindowTab(HWND hwnd) + { + var dialogWindow = GetDialogWindow(hwnd); + if (dialogWindow == null) return null; + var dialogWindowTab = dialogWindow.GetCurrentTab(); + return dialogWindowTab; + } + + private static IQuickSwitchDialogWindow GetDialogWindow(HWND hwnd) + { + // First check dialog window + lock (_dialogWindowLock) + { + if (_dialogWindow != null && _dialogWindow.Handle == hwnd) + { + return _dialogWindow; + } + } + + // Then check all dialog windows + foreach (var dialog in _quickSwitchDialogs) + { + if (dialog.DialogWindow.Handle == hwnd) + { + return dialog.DialogWindow; + } + } + + // Finally search for the dialog window + foreach (var dialog in _quickSwitchDialogs) + { + if (dialog.CheckDialogWindow(hwnd)) + { + return dialog.DialogWindow; + } + } + + return null; + } + + private static async Task JumpToPathAsync(IQuickSwitchDialogWindowTab dialog, string path, bool isFile, bool auto = false) + { + // Jump after flow launcher window vanished (after JumpAction returned true) + // and the dialog had been in the foreground. + var dialogHandle = dialog.Handle; + var timeOut = !SpinWait.SpinUntil(() => Win32Helper.IsForegroundWindow(dialogHandle), 1000); + if (timeOut) return false; + + // Assume that the dialog is in the foreground now + await _navigationLock.WaitAsync(); + try + { + bool result; + if (isFile) + { + switch (_settings.QuickSwitchFileResultBehaviour) + { + case QuickSwitchFileResultBehaviours.FullPath: + Log.Debug(ClassName, $"File Jump FullPath: {path}"); + result = FileJump(path, dialog); + break; + case QuickSwitchFileResultBehaviours.FullPathOpen: + Log.Debug(ClassName, $"File Jump FullPathOpen: {path}"); + result = FileJump(path, dialog, openFile: true); + break; + case QuickSwitchFileResultBehaviours.Directory: + Log.Debug(ClassName, $"File Jump Directory (Auto: {auto}): {path}"); + result = DirJump(Path.GetDirectoryName(path), dialog, auto); + break; + default: + return false; + } + } + else + { + Log.Debug(ClassName, $"Dir Jump: {path}"); + result = DirJump(path, dialog, auto); + } + + if (result) + { + lock (_autoSwitchedDialogsLock) + { + _autoSwitchedDialogs.Add(dialogHandle); + } + } + + return result; + } + catch (System.Exception e) + { + Log.Exception(ClassName, "Failed to jump to path", e); + return false; + } + finally + { + _navigationLock.Release(); + } + } + + private static bool FileJump(string filePath, IQuickSwitchDialogWindowTab dialog, bool openFile = false) + { + if (!dialog.JumpFile(filePath)) + { + Log.Error(ClassName, "Failed to jump file"); + return false; + } + + if (openFile && !dialog.Open()) + { + Log.Error(ClassName, "Failed to open file"); + return false; + } + + return true; + } + + private static bool DirJump(string dirPath, IQuickSwitchDialogWindowTab dialog, bool auto = false) + { + if (!dialog.JumpFolder(dirPath, auto)) + { + Log.Error(ClassName, "Failed to jump folder"); + return false; + } + + return true; + } + + #endregion + + #region Dispose + + public static void Dispose() + { + // Reset flags + _enabled = false; + _initialized = false; + + // Unhook events + if (!_foregroundChangeHook.IsNull) + { + PInvoke.UnhookWinEvent(_foregroundChangeHook); + _foregroundChangeHook = HWINEVENTHOOK.Null; + } + if (!_locationChangeHook.IsNull) + { + PInvoke.UnhookWinEvent(_locationChangeHook); + _locationChangeHook = HWINEVENTHOOK.Null; + } + if (!_moveSizeHook.IsNull) + { + PInvoke.UnhookWinEvent(_moveSizeHook); + _moveSizeHook = HWINEVENTHOOK.Null; + } + if (!_destroyChangeHook.IsNull) + { + PInvoke.UnhookWinEvent(_destroyChangeHook); + _destroyChangeHook = HWINEVENTHOOK.Null; + } + + // Dispose explorers + foreach (var explorer in _quickSwitchExplorers) + { + explorer.Dispose(); + } + _quickSwitchExplorers.Clear(); + lock (_lastExplorerLock) + { + _lastExplorer = null; + } + + // Dispose dialogs + foreach (var dialog in _quickSwitchDialogs) + { + dialog.Dispose(); + } + _quickSwitchDialogs.Clear(); + lock (_dialogWindowLock) + { + _dialogWindow = null; + } + + // Dispose locks + _foregroundChangeLock.Dispose(); + _navigationLock.Dispose(); + + // Stop drag move timer + if (_dragMoveTimer != null) + { + _dragMoveTimer.Stop(); + _dragMoveTimer = null; + } + } + + #endregion + } +} diff --git a/Flow.Launcher.Infrastructure/UserSettings/Settings.cs b/Flow.Launcher.Infrastructure/UserSettings/Settings.cs index 027eb3f926d..2759d526a10 100644 --- a/Flow.Launcher.Infrastructure/UserSettings/Settings.cs +++ b/Flow.Launcher.Infrastructure/UserSettings/Settings.cs @@ -52,6 +52,7 @@ public void Save() public string SettingWindowHotkey { get; set; } = $"Ctrl+I"; public string CycleHistoryUpHotkey { get; set; } = $"{KeyConstant.Alt} + Up"; public string CycleHistoryDownHotkey { get; set; } = $"{KeyConstant.Alt} + Down"; + public string QuickSwitchHotkey { get; set; } = $"{KeyConstant.Alt} + G"; private string _language = Constant.SystemLanguageCode; public string Language @@ -77,7 +78,7 @@ public string Theme } } public bool UseDropShadowEffect { get; set; } = true; - public BackdropTypes BackdropType{ get; set; } = BackdropTypes.None; + public BackdropTypes BackdropType { get; set; } = BackdropTypes.None; /* Appearance Settings. It should be separated from the setting later.*/ public double WindowHeightSize { get; set; } = 42; @@ -277,6 +278,21 @@ public CustomBrowserViewModel CustomBrowser } }; + public bool EnableQuickSwitch { get; set; } = true; + + public bool AutoQuickSwitch { get; set; } = false; + + public bool ShowQuickSwitchWindow { get; set; } = true; + + [JsonConverter(typeof(JsonStringEnumConverter))] + public QuickSwitchWindowPositions QuickSwitchWindowPosition { get; set; } = QuickSwitchWindowPositions.UnderDialog; + + [JsonConverter(typeof(JsonStringEnumConverter))] + public QuickSwitchResultBehaviours QuickSwitchResultBehaviour { get; set; } = QuickSwitchResultBehaviours.LeftClick; + + [JsonConverter(typeof(JsonStringEnumConverter))] + public QuickSwitchFileResultBehaviours QuickSwitchFileResultBehaviour { get; set; } = QuickSwitchFileResultBehaviours.FullPath; + [JsonConverter(typeof(JsonStringEnumConverter))] public LOGLEVEL LogLevel { get; set; } = LOGLEVEL.INFO; @@ -537,4 +553,23 @@ public enum BackdropTypes Mica, MicaAlt } + + public enum QuickSwitchWindowPositions + { + UnderDialog, + FollowDefault + } + + public enum QuickSwitchResultBehaviours + { + LeftClick, + RightClick + } + + public enum QuickSwitchFileResultBehaviours + { + FullPath, + FullPathOpen, + Directory + } } diff --git a/Flow.Launcher.Infrastructure/Win32Helper.cs b/Flow.Launcher.Infrastructure/Win32Helper.cs index 783ade14ebe..0f66fa9e094 100644 --- a/Flow.Launcher.Infrastructure/Win32Helper.cs +++ b/Flow.Launcher.Infrastructure/Win32Helper.cs @@ -3,6 +3,7 @@ using System.ComponentModel; using System.Diagnostics; using System.Globalization; +using System.IO; using System.Linq; using System.Runtime.InteropServices; using System.Threading; @@ -13,9 +14,11 @@ using System.Windows.Media; using Flow.Launcher.Infrastructure.UserSettings; using Microsoft.Win32; +using Microsoft.Win32.SafeHandles; using Windows.Win32; using Windows.Win32.Foundation; using Windows.Win32.Graphics.Dwm; +using Windows.Win32.System.Threading; using Windows.Win32.UI.Input.KeyboardAndMouse; using Windows.Win32.UI.WindowsAndMessaging; using Point = System.Windows.Point; @@ -136,6 +139,11 @@ public static bool IsForegroundWindow(Window window) return IsForegroundWindow(GetWindowHandle(window)); } + public static bool IsForegroundWindow(nint handle) + { + return IsForegroundWindow(new HWND(handle)); + } + internal static bool IsForegroundWindow(HWND handle) { return handle.Equals(PInvoke.GetForegroundWindow()); @@ -337,6 +345,16 @@ internal static HWND GetWindowHandle(Window window, bool ensure = false) return new(windowHelper.Handle); } + internal static HWND GetMainWindowHandle() + { + // When application is exiting, the Application.Current will be null + if (Application.Current == null) return HWND.Null; + + // Get the FL main window + var hwnd = GetWindowHandle(Application.Current.MainWindow, true); + return hwnd; + } + #endregion #region STA Thread @@ -753,5 +771,64 @@ private static bool TryGetNotoFont(string langKey, out string notoFont) } #endregion + + #region Window Rect + + public static unsafe bool GetWindowRect(nint handle, out Rect outRect) + { + var rect = new RECT(); + var result = PInvoke.GetWindowRect(new(handle), &rect); + if (!result) + { + outRect = new Rect(); + return false; + } + + // Convert RECT to Rect + outRect = new Rect( + rect.left, + rect.top, + rect.right - rect.left, + rect.bottom - rect.top + ); + return true; + } + + #endregion + + #region Window Process + + internal static unsafe string GetProcessNameFromHwnd(HWND hWnd) + { + return Path.GetFileName(GetProcessPathFromHwnd(hWnd)); + } + + internal static unsafe string GetProcessPathFromHwnd(HWND hWnd) + { + uint pid; + var threadId = PInvoke.GetWindowThreadProcessId(hWnd, &pid); + if (threadId == 0) return string.Empty; + + var process = PInvoke.OpenProcess(PROCESS_ACCESS_RIGHTS.PROCESS_QUERY_LIMITED_INFORMATION, false, pid); + if (process.Value != IntPtr.Zero) + { + using var safeHandle = new SafeProcessHandle(process.Value, true); + uint capacity = 2000; + Span buffer = new char[capacity]; + fixed (char* pBuffer = buffer) + { + if (!PInvoke.QueryFullProcessImageName(safeHandle, PROCESS_NAME_FORMAT.PROCESS_NAME_WIN32, (PWSTR)pBuffer, ref capacity)) + { + return string.Empty; + } + + return buffer[..(int)capacity].ToString(); + } + } + + return string.Empty; + } + + #endregion } } diff --git a/Flow.Launcher.Plugin/Interfaces/IAsyncQuickSwitch.cs b/Flow.Launcher.Plugin/Interfaces/IAsyncQuickSwitch.cs new file mode 100644 index 00000000000..477b44975e7 --- /dev/null +++ b/Flow.Launcher.Plugin/Interfaces/IAsyncQuickSwitch.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using System.Threading; + +namespace Flow.Launcher.Plugin +{ + /// + /// Asynchronous Quick Switch Model + /// + public interface IAsyncQuickSwitch : IFeatures + { + /// + /// Asynchronous querying for quick switch window + /// + /// + /// If the Querying method requires high IO transmission + /// or performing CPU intense jobs (performing better with cancellation), please use this IAsyncQuickSwitch interface + /// + /// Query to search + /// Cancel when querying job is obsolete + /// + Task> QueryQuickSwitchAsync(Query query, CancellationToken token); + } +} diff --git a/Flow.Launcher.Plugin/Interfaces/IQuickSwitch.cs b/Flow.Launcher.Plugin/Interfaces/IQuickSwitch.cs new file mode 100644 index 00000000000..5e43a73acf2 --- /dev/null +++ b/Flow.Launcher.Plugin/Interfaces/IQuickSwitch.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Flow.Launcher.Plugin +{ + /// + /// Synchronous Quick Switch Model + /// + /// If the Querying method requires high IO transmission + /// or performaing CPU intense jobs (performing better with cancellation), please try the IAsyncQuickSwitch interface + /// + /// + public interface IQuickSwitch : IAsyncQuickSwitch + { + /// + /// Querying for quick switch window + /// + /// This method will be called within a Task.Run, + /// so please avoid synchrously wait for long. + /// + /// + /// Query to search + /// + List QueryQuickSwitch(Query query); + + Task> IAsyncQuickSwitch.QueryQuickSwitchAsync(Query query, CancellationToken token) => Task.Run(() => QueryQuickSwitch(query)); + } +} diff --git a/Flow.Launcher.Plugin/QuickSwitchResult.cs b/Flow.Launcher.Plugin/QuickSwitchResult.cs new file mode 100644 index 00000000000..0940bf85fb3 --- /dev/null +++ b/Flow.Launcher.Plugin/QuickSwitchResult.cs @@ -0,0 +1,92 @@ +namespace Flow.Launcher.Plugin +{ + /// + /// Describes a result of a executed by a plugin in quick switch window + /// + public class QuickSwitchResult : Result + { + /// + /// This holds the path which can be provided by plugin to be navigated to the + /// file dialog when records in quick switch window is right clicked on a result. + /// + public required string QuickSwitchPath { get; init; } + + /// + /// Clones the current quick switch result + /// + public new QuickSwitchResult Clone() + { + return new QuickSwitchResult + { + Title = Title, + SubTitle = SubTitle, + ActionKeywordAssigned = ActionKeywordAssigned, + CopyText = CopyText, + AutoCompleteText = AutoCompleteText, + IcoPath = IcoPath, + BadgeIcoPath = BadgeIcoPath, + RoundedIcon = RoundedIcon, + Icon = Icon, + BadgeIcon = BadgeIcon, + Glyph = Glyph, + Action = Action, + AsyncAction = AsyncAction, + Score = Score, + TitleHighlightData = TitleHighlightData, + OriginQuery = OriginQuery, + PluginDirectory = PluginDirectory, + ContextData = ContextData, + PluginID = PluginID, + TitleToolTip = TitleToolTip, + SubTitleToolTip = SubTitleToolTip, + PreviewPanel = PreviewPanel, + ProgressBar = ProgressBar, + ProgressBarColor = ProgressBarColor, + Preview = Preview, + AddSelectedCount = AddSelectedCount, + RecordKey = RecordKey, + ShowBadge = ShowBadge, + QuickSwitchPath = QuickSwitchPath + }; + } + + /// + /// Convert to . + /// + public static QuickSwitchResult From(Result result, string quickSwitchPath) + { + return new QuickSwitchResult + { + Title = result.Title, + SubTitle = result.SubTitle, + ActionKeywordAssigned = result.ActionKeywordAssigned, + CopyText = result.CopyText, + AutoCompleteText = result.AutoCompleteText, + IcoPath = result.IcoPath, + BadgeIcoPath = result.BadgeIcoPath, + RoundedIcon = result.RoundedIcon, + Icon = result.Icon, + BadgeIcon = result.BadgeIcon, + Glyph = result.Glyph, + Action = result.Action, + AsyncAction = result.AsyncAction, + Score = result.Score, + TitleHighlightData = result.TitleHighlightData, + OriginQuery = result.OriginQuery, + PluginDirectory = result.PluginDirectory, + ContextData = result.ContextData, + PluginID = result.PluginID, + TitleToolTip = result.TitleToolTip, + SubTitleToolTip = result.SubTitleToolTip, + PreviewPanel = result.PreviewPanel, + ProgressBar = result.ProgressBar, + ProgressBarColor = result.ProgressBarColor, + Preview = result.Preview, + AddSelectedCount = result.AddSelectedCount, + RecordKey = result.RecordKey, + ShowBadge = result.ShowBadge, + QuickSwitchPath = quickSwitchPath + }; + } + } +} diff --git a/Flow.Launcher.Plugin/Result.cs b/Flow.Launcher.Plugin/Result.cs index f0fcd48ffc0..a459e9ee663 100644 --- a/Flow.Launcher.Plugin/Result.cs +++ b/Flow.Launcher.Plugin/Result.cs @@ -307,7 +307,7 @@ public Result Clone() Preview = Preview, AddSelectedCount = AddSelectedCount, RecordKey = RecordKey, - ShowBadge = ShowBadge, + ShowBadge = ShowBadge }; } diff --git a/Flow.Launcher/App.xaml.cs b/Flow.Launcher/App.xaml.cs index 942e9447037..10150394cb6 100644 --- a/Flow.Launcher/App.xaml.cs +++ b/Flow.Launcher/App.xaml.cs @@ -16,6 +16,7 @@ using Flow.Launcher.Infrastructure.Http; using Flow.Launcher.Infrastructure.Image; using Flow.Launcher.Infrastructure.Logger; +using Flow.Launcher.Infrastructure.QuickSwitch; using Flow.Launcher.Infrastructure.Storage; using Flow.Launcher.Infrastructure.UserSettings; using Flow.Launcher.Plugin; @@ -206,6 +207,9 @@ await API.StopwatchLogInfoAsync(ClassName, "Startup cost", async () => // Initialize theme for main window Ioc.Default.GetRequiredService().ChangeTheme(); + QuickSwitch.InitializeQuickSwitch(); + QuickSwitch.SetupQuickSwitch(_settings.EnableQuickSwitch); + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); RegisterExitEvents(); @@ -353,6 +357,7 @@ protected virtual void Dispose(bool disposing) // since some resources owned by the thread need to be disposed. _mainWindow?.Dispatcher.Invoke(_mainWindow.Dispose); _mainVM?.Dispose(); + QuickSwitch.Dispose(); } API.LogInfo(ClassName, "End Flow Launcher dispose ----------------------------------------------------"); diff --git a/Flow.Launcher/Flow.Launcher.csproj b/Flow.Launcher/Flow.Launcher.csproj index 8d43eff9870..e5e2b603b35 100644 --- a/Flow.Launcher/Flow.Launcher.csproj +++ b/Flow.Launcher/Flow.Launcher.csproj @@ -89,7 +89,6 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - @@ -97,7 +96,6 @@ - all diff --git a/Flow.Launcher/Helper/HotKeyMapper.cs b/Flow.Launcher/Helper/HotKeyMapper.cs index e5fabb3a89f..aa598141f12 100644 --- a/Flow.Launcher/Helper/HotKeyMapper.cs +++ b/Flow.Launcher/Helper/HotKeyMapper.cs @@ -1,11 +1,12 @@ -using Flow.Launcher.Infrastructure.Hotkey; +using System; +using ChefKeys; +using CommunityToolkit.Mvvm.DependencyInjection; +using Flow.Launcher.Infrastructure.Hotkey; +using Flow.Launcher.Infrastructure.QuickSwitch; using Flow.Launcher.Infrastructure.UserSettings; -using System; +using Flow.Launcher.ViewModel; using NHotkey; using NHotkey.Wpf; -using Flow.Launcher.ViewModel; -using ChefKeys; -using CommunityToolkit.Mvvm.DependencyInjection; namespace Flow.Launcher.Helper; @@ -22,6 +23,10 @@ internal static void Initialize() _settings = Ioc.Default.GetService(); SetHotkey(_settings.Hotkey, OnToggleHotkey); + if (_settings.EnableQuickSwitch) + { + SetHotkey(_settings.QuickSwitchHotkey, QuickSwitch.OnToggleHotkey); + } LoadCustomPluginHotkey(); } diff --git a/Flow.Launcher/HotkeyControl.xaml.cs b/Flow.Launcher/HotkeyControl.xaml.cs index 26272712757..d11242efffa 100644 --- a/Flow.Launcher/HotkeyControl.xaml.cs +++ b/Flow.Launcher/HotkeyControl.xaml.cs @@ -109,7 +109,8 @@ public enum HotkeyType SelectPrevItemHotkey, SelectPrevItemHotkey2, SelectNextItemHotkey, - SelectNextItemHotkey2 + SelectNextItemHotkey2, + QuickSwitchHotkey, } // We can initialize settings in static field because it has been constructed in App constuctor @@ -140,6 +141,7 @@ public string Hotkey HotkeyType.SelectPrevItemHotkey2 => _settings.SelectPrevItemHotkey2, HotkeyType.SelectNextItemHotkey => _settings.SelectNextItemHotkey, HotkeyType.SelectNextItemHotkey2 => _settings.SelectNextItemHotkey2, + HotkeyType.QuickSwitchHotkey => _settings.QuickSwitchHotkey, _ => throw new System.NotImplementedException("Hotkey type not set") }; } @@ -196,6 +198,9 @@ public string Hotkey case HotkeyType.SelectNextItemHotkey2: _settings.SelectNextItemHotkey2 = value; break; + case HotkeyType.QuickSwitchHotkey: + _settings.QuickSwitchHotkey = value; + break; default: throw new System.NotImplementedException("Hotkey type not set"); } diff --git a/Flow.Launcher/Languages/en.xaml b/Flow.Launcher/Languages/en.xaml index 22ab2016cc0..916d6a57322 100644 --- a/Flow.Launcher/Languages/en.xaml +++ b/Flow.Launcher/Languages/en.xaml @@ -311,6 +311,28 @@ Show Result Badges For supported plugins, badges are displayed to help distinguish them more easily. Show Result Badges for Global Query Only + Show badges for global query results only + Quick Switch + Enter shortcut to quickly navigate the path of a file dialog to the path of the current file manager. + Quick Switch + Quickly navigate to the path of the current file manager when a file dialog is opened. + Quick Switch Automatically + Automatically navigate to the path of the current file manager when a file dialog is opened. (It can possibly cause dialog apps force close) + Show Quick Switch Window + Show quick switch search window when file dialogs are opened to navigate its path (Only work for file open dialog). + Quick Switch Window Position + Select position for quick switch window + Fixed under dialogs. Displayed after dialogs are created and until it is closed + Floating as search window. Displayed when activated like search window + Quick Switch Result Navigation Behaviour + Behaviour to navigate file dialogs to paths of the results + Left click + Right click + Quick Switch File Navigation Behaviour + Behaviour to navigate file dialogs when paths of the results are files + Fill full path in file name box + Fill full path in file name box and open + Fill directory in path box HTTP Proxy diff --git a/Flow.Launcher/MainWindow.xaml.cs b/Flow.Launcher/MainWindow.xaml.cs index bb29d78e5e8..d2d8ca991c5 100644 --- a/Flow.Launcher/MainWindow.xaml.cs +++ b/Flow.Launcher/MainWindow.xaml.cs @@ -19,6 +19,7 @@ using Flow.Launcher.Infrastructure; using Flow.Launcher.Infrastructure.Hotkey; using Flow.Launcher.Infrastructure.Image; +using Flow.Launcher.Infrastructure.QuickSwitch; using Flow.Launcher.Infrastructure.UserSettings; using Flow.Launcher.Plugin.SharedCommands; using Flow.Launcher.ViewModel; @@ -113,7 +114,7 @@ private void OnSourceInitialized(object sender, EventArgs e) Win32Helper.DisableControlBox(this); } - private void OnLoaded(object sender, RoutedEventArgs _) + private void OnLoaded(object sender, RoutedEventArgs e) { // Check first launch if (_settings.FirstLaunch) @@ -141,10 +142,12 @@ private void OnLoaded(object sender, RoutedEventArgs _) if (_settings.HideOnStartup) { _viewModel.Hide(); + _viewModel.InitializeVisibilityStatus(false); } else { _viewModel.Show(); + _viewModel.InitializeVisibilityStatus(true); // When HideOnStartup is off and UseAnimation is on, // there was a bug where the clock would not appear at all on the initial launch // So we need to forcibly trigger animation here to ensure the clock is visible @@ -187,6 +190,9 @@ private void OnLoaded(object sender, RoutedEventArgs _) // Without this part, when shown for the first time, switching the context menu does not move the cursor to the end. _viewModel.QueryTextCursorMovedToEnd = false; + // Register quick switch events + InitializeQuickSwitch(); + // View model property changed event _viewModel.PropertyChanged += (o, e) => { @@ -199,7 +205,7 @@ private void OnLoaded(object sender, RoutedEventArgs _) if (_viewModel.MainWindowVisibilityStatus) { // Play sound effect before activing the window - if (_settings.UseSound) + if (_settings.UseSound && !_viewModel.IsQuickSwitchWindowUnderDialog()) { SoundPlay(); } @@ -222,7 +228,7 @@ private void OnLoaded(object sender, RoutedEventArgs _) QueryTextBox.Focus(); // Play window animation - if (_settings.UseAnimation) + if (_settings.UseAnimation && !_viewModel.IsQuickSwitchWindowUnderDialog()) { WindowAnimation(); } @@ -345,6 +351,11 @@ private void OnClosed(object sender, EventArgs e) private void OnLocationChanged(object sender, EventArgs e) { + if (_viewModel.IsQuickSwitchWindowUnderDialog()) + { + return; + } + if (IsLoaded) { _settings.WindowLeft = Left; @@ -354,6 +365,11 @@ private void OnLocationChanged(object sender, EventArgs e) private async void OnDeactivated(object sender, EventArgs e) { + if (_viewModel.IsQuickSwitchWindowUnderDialog()) + { + return; + } + _settings.WindowLeft = Left; _settings.WindowTop = Top; @@ -492,6 +508,11 @@ private async void OnContextMenusForSettingsClick(object sender, RoutedEventArgs private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled) { + if (_viewModel.IsQuickSwitchWindowUnderDialog()) + { + return IntPtr.Zero; + } + if (msg == Win32Helper.WM_ENTERSIZEMOVE) { _initialWidth = (int)Width; @@ -690,11 +711,19 @@ private void InitializeContextMenu() #region Window Position - private void UpdatePosition() + public void UpdatePosition() { - // Initialize call twice to work around multi-display alignment issue- https://github.com/Flow-Launcher/Flow.Launcher/issues/2910 - InitializePosition(); - InitializePosition(); + // Intialize call twice to work around multi-display alignment issue- https://github.com/Flow-Launcher/Flow.Launcher/issues/2910 + if (_viewModel.IsQuickSwitchWindowUnderDialog()) + { + InitializeQuickSwitchPosition(); + InitializeQuickSwitchPosition(); + } + else + { + InitializePosition(); + InitializePosition(); + } } private async Task PositionResetAsync() @@ -1245,6 +1274,46 @@ private void QueryTextBox_TextChanged1(object sender, TextChangedEventArgs e) #endregion + #region Quick Switch + + private void InitializeQuickSwitch() + { + QuickSwitch.ShowQuickSwitchWindowAsync = _viewModel.SetupQuickSwitchAsync; + QuickSwitch.UpdateQuickSwitchWindow = InitializeQuickSwitchPosition; + QuickSwitch.ResetQuickSwitchWindow = _viewModel.ResetQuickSwitch; + QuickSwitch.HideQuickSwitchWindow = _viewModel.HideQuickSwitch; + } + + private void InitializeQuickSwitchPosition() + { + if (_viewModel.DialogWindowHandle == nint.Zero || !_viewModel.MainWindowVisibilityStatus) return; + if (!_viewModel.IsQuickSwitchWindowUnderDialog()) return; + + // Get dialog window rect + var result = Win32Helper.GetWindowRect(_viewModel.DialogWindowHandle, out var window); + if (!result) return; + + // Move window below the bottom of the dialog and keep it center + Top = VerticalBottom(window); + Left = HorizonCenter(window); + } + + private double HorizonCenter(Rect window) + { + var dip1 = Win32Helper.TransformPixelsToDIP(this, window.X, 0); + var dip2 = Win32Helper.TransformPixelsToDIP(this, window.Width, 0); + var left = (dip2.X - ActualWidth) / 2 + dip1.X; + return left; + } + + private double VerticalBottom(Rect window) + { + var dip1 = Win32Helper.TransformPixelsToDIP(this, 0, window.Bottom); + return dip1.Y; + } + + #endregion + #region IDisposable protected virtual void Dispose(bool disposing) diff --git a/Flow.Launcher/SettingPages/ViewModels/SettingsPaneGeneralViewModel.cs b/Flow.Launcher/SettingPages/ViewModels/SettingsPaneGeneralViewModel.cs index 840269b037e..44dcd3df3c8 100644 --- a/Flow.Launcher/SettingPages/ViewModels/SettingsPaneGeneralViewModel.cs +++ b/Flow.Launcher/SettingPages/ViewModels/SettingsPaneGeneralViewModel.cs @@ -8,6 +8,7 @@ using Flow.Launcher.Core.Resource; using Flow.Launcher.Helper; using Flow.Launcher.Infrastructure; +using Flow.Launcher.Infrastructure.QuickSwitch; using Flow.Launcher.Infrastructure.UserSettings; using Flow.Launcher.Plugin; using Flow.Launcher.Plugin.SharedModels; @@ -145,6 +146,40 @@ public bool PortableMode public List LastQueryModes { get; } = DropdownDataGeneric.GetValues("LastQuery"); + public bool EnableQuickSwitch + { + get => Settings.EnableQuickSwitch; + set + { + if (Settings.EnableQuickSwitch != value) + { + Settings.EnableQuickSwitch = value; + QuickSwitch.SetupQuickSwitch(value); + if (Settings.EnableQuickSwitch) + { + HotKeyMapper.SetHotkey(new(Settings.QuickSwitchHotkey), QuickSwitch.OnToggleHotkey); + } + else + { + HotKeyMapper.RemoveHotkey(Settings.QuickSwitchHotkey); + } + } + } + } + + public class QuickSwitchWindowPositionData : DropdownDataGeneric { } + public class QuickSwitchResultBehaviourData : DropdownDataGeneric { } + public class QuickSwitchFileResultBehaviourData : DropdownDataGeneric { } + + public List QuickSwitchWindowPositions { get; } = + DropdownDataGeneric.GetValues("QuickSwitchWindowPosition"); + + public List QuickSwitchResultBehaviours { get; } = + DropdownDataGeneric.GetValues("QuickSwitchResultBehaviour"); + + public List QuickSwitchFileResultBehaviours { get; } = + DropdownDataGeneric.GetValues("QuickSwitchFileResultBehaviour"); + public int SearchDelayTimeValue { get => Settings.SearchDelayTime; @@ -177,6 +212,9 @@ private void UpdateEnumDropdownLocalizations() DropdownDataGeneric.UpdateLabels(SearchWindowAligns); DropdownDataGeneric.UpdateLabels(SearchPrecisionScores); DropdownDataGeneric.UpdateLabels(LastQueryModes); + DropdownDataGeneric.UpdateLabels(QuickSwitchWindowPositions); + DropdownDataGeneric.UpdateLabels(QuickSwitchResultBehaviours); + DropdownDataGeneric.UpdateLabels(QuickSwitchFileResultBehaviours); } public string Language diff --git a/Flow.Launcher/SettingPages/ViewModels/SettingsPaneHotkeyViewModel.cs b/Flow.Launcher/SettingPages/ViewModels/SettingsPaneHotkeyViewModel.cs index 7a7c19dd358..faa3969c9f0 100644 --- a/Flow.Launcher/SettingPages/ViewModels/SettingsPaneHotkeyViewModel.cs +++ b/Flow.Launcher/SettingPages/ViewModels/SettingsPaneHotkeyViewModel.cs @@ -4,6 +4,7 @@ using Flow.Launcher.Helper; using Flow.Launcher.Infrastructure; using Flow.Launcher.Infrastructure.Hotkey; +using Flow.Launcher.Infrastructure.QuickSwitch; using Flow.Launcher.Infrastructure.UserSettings; using Flow.Launcher.Plugin; @@ -34,6 +35,15 @@ private void SetTogglingHotkey(HotkeyModel hotkey) HotKeyMapper.SetHotkey(hotkey, HotKeyMapper.OnToggleHotkey); } + [RelayCommand] + private void SetQuickSwitchHotkey(HotkeyModel hotkey) + { + if (Settings.EnableQuickSwitch) + { + HotKeyMapper.SetHotkey(hotkey, QuickSwitch.OnToggleHotkey); + } + } + [RelayCommand] private void CustomHotkeyDelete() { diff --git a/Flow.Launcher/SettingPages/Views/SettingsPaneGeneral.xaml b/Flow.Launcher/SettingPages/Views/SettingsPaneGeneral.xaml index c0c5613de04..53311e3ea87 100644 --- a/Flow.Launcher/SettingPages/Views/SettingsPaneGeneral.xaml +++ b/Flow.Launcher/SettingPages/Views/SettingsPaneGeneral.xaml @@ -202,6 +202,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + + + _emptyResult = new List(); + private readonly IReadOnlyList _emptyQuickSwitchResult = new List(); private readonly PluginMetadata _historyMetadata = new() { @@ -199,7 +201,8 @@ private void RegisterViewUpdate() var resultUpdateChannel = Channel.CreateUnbounded(); _resultsUpdateChannelWriter = resultUpdateChannel.Writer; _resultsViewUpdateTask = - Task.Run(UpdateActionAsync).ContinueWith(continueAction, CancellationToken.None, TaskContinuationOptions.OnlyOnFaulted, TaskScheduler.Default); + Task.Run(UpdateActionAsync).ContinueWith(continueAction, + CancellationToken.None, TaskContinuationOptions.OnlyOnFaulted, TaskScheduler.Default); async Task UpdateActionAsync() { @@ -250,8 +253,16 @@ public void RegisterResultsUpdatedEvent() var token = e.Token == default ? _updateToken : e.Token; - // make a clone to avoid possible issue that plugin will also change the list and items when updating view model - var resultsCopy = DeepCloneResults(e.Results, token); + IReadOnlyList resultsCopy; + if (e.Results == null) + { + resultsCopy = _emptyResult; + } + else + { + // make a clone to avoid possible issue that plugin will also change the list and items when updating view model + resultsCopy = DeepCloneResults(e.Results, false, token); + } foreach (var result in resultsCopy) { @@ -357,12 +368,30 @@ public void ForwardHistory() [RelayCommand] private void LoadContextMenu() { + // For quick switch and right click mode, we need to navigate to the path + if (_isQuickSwitch && Settings.QuickSwitchResultBehaviour == QuickSwitchResultBehaviours.RightClick) + { + if (SelectedResults.SelectedItem != null && DialogWindowHandle != nint.Zero) + { + var result = SelectedResults.SelectedItem.Result; + if (result is QuickSwitchResult quickSwitchResult) + { + Win32Helper.SetForegroundWindow(DialogWindowHandle); + _ = Task.Run(() => QuickSwitch.JumpToPathAsync(DialogWindowHandle, quickSwitchResult.QuickSwitchPath)); + } + } + return; + } + + // For query mode, we load context menu if (QueryResultsSelected()) { // When switch to ContextMenu from QueryResults, but no item being chosen, should do nothing // i.e. Shift+Enter/Ctrl+O right after Alt + Space should do nothing if (SelectedResults.SelectedItem != null) + { SelectedResults = ContextMenu; + } } else { @@ -432,12 +461,34 @@ private async Task OpenResultAsync(string index) return; } - var hideWindow = await result.ExecuteAsync(new ActionContext + // For quick switch and left click mode, we need to navigate to the path + if (_isQuickSwitch && Settings.QuickSwitchResultBehaviour == QuickSwitchResultBehaviours.LeftClick) + { + Hide(); + + if (SelectedResults.SelectedItem != null && DialogWindowHandle != nint.Zero) + { + if (result is QuickSwitchResult quickSwitchResult) + { + Win32Helper.SetForegroundWindow(DialogWindowHandle); + _ = Task.Run(() => QuickSwitch.JumpToPathAsync(DialogWindowHandle, quickSwitchResult.QuickSwitchPath)); + } + } + } + // For query mode, we execute the result + else { - // not null means pressing modifier key + number, should ignore the modifier key - SpecialKeyState = index is not null ? SpecialKeyState.Default : GlobalHotkey.CheckModifiers() - }) - .ConfigureAwait(false); + var hideWindow = await result.ExecuteAsync(new ActionContext + { + // not null means pressing modifier key + number, should ignore the modifier key + SpecialKeyState = index is not null ? SpecialKeyState.Default : GlobalHotkey.CheckModifiers() + }).ConfigureAwait(false); + + if (hideWindow) + { + Hide(); + } + } if (QueryResultsSelected()) { @@ -445,26 +496,33 @@ private async Task OpenResultAsync(string index) _history.Add(result.OriginQuery.RawQuery); lastHistoryIndex = 1; } - - if (hideWindow) - { - Hide(); - } } - private static IReadOnlyList DeepCloneResults(IReadOnlyList results, CancellationToken token = default) + private static IReadOnlyList DeepCloneResults(IReadOnlyList results, bool isQuickSwitch, CancellationToken token = default) { var resultsCopy = new List(); - foreach (var result in results.ToList()) + + if (isQuickSwitch) { - if (token.IsCancellationRequested) + foreach (var result in results.ToList()) { - break; + if (token.IsCancellationRequested) break; + + var resultCopy = ((QuickSwitchResult)result).Clone(); + resultsCopy.Add(resultCopy); } + } + else + { + foreach (var result in results.ToList()) + { + if (token.IsCancellationRequested) break; - var resultCopy = result.Clone(); - resultsCopy.Add(resultCopy); + var resultCopy = result.Clone(); + resultsCopy.Add(resultCopy); + } } + return resultsCopy; } @@ -1240,25 +1298,21 @@ private async Task QueryResultsAsync(bool searchDelay, bool isReQuery = false, b if (query == null) // shortcut expanded { - App.API.LogDebug(ClassName, $"Clear query results"); - - // Hide and clear results again because running query may show and add some results - Results.Visibility = Visibility.Collapsed; - Results.Clear(); - - // Reset plugin icon - PluginIconPath = null; - PluginIconSource = null; - SearchIconVisibility = Visibility.Visible; - - // Hide progress bar again because running query may set this to visible - ProgressBarVisibility = Visibility.Hidden; + ClearResults(); return; } App.API.LogDebug(ClassName, $"Start query with ActionKeyword <{query.ActionKeyword}> and RawQuery <{query.RawQuery}>"); var currentIsHomeQuery = query.RawQuery == string.Empty; + var currentIsQuickSwitch = _isQuickSwitch; + + // Do not show home page for quick switch window + if (currentIsHomeQuery && currentIsQuickSwitch) + { + ClearResults(); + return; + } _updateSource?.Dispose(); @@ -1292,7 +1346,7 @@ private async Task QueryResultsAsync(bool searchDelay, bool isReQuery = false, b } else { - plugins = PluginManager.ValidPluginsForQuery(query); + plugins = PluginManager.ValidPluginsForQuery(query, currentIsQuickSwitch); if (plugins.Count == 1) { @@ -1386,6 +1440,23 @@ private async Task QueryResultsAsync(bool searchDelay, bool isReQuery = false, b } // Local function + void ClearResults() + { + App.API.LogDebug(ClassName, $"Clear query results"); + + // Hide and clear results again because running query may show and add some results + Results.Visibility = Visibility.Collapsed; + Results.Clear(); + + // Reset plugin icon + PluginIconPath = null; + PluginIconSource = null; + SearchIconVisibility = Visibility.Visible; + + // Hide progress bar again because running query may set this to visible + ProgressBarVisibility = Visibility.Hidden; + } + async Task QueryTaskAsync(PluginPair plugin, CancellationToken token) { App.API.LogDebug(ClassName, $"Wait for querying plugin <{plugin.Metadata.Name}>"); @@ -1403,9 +1474,11 @@ async Task QueryTaskAsync(PluginPair plugin, CancellationToken token) // Task.Yield will force it to run in ThreadPool await Task.Yield(); - var results = currentIsHomeQuery ? - await PluginManager.QueryHomeForPluginAsync(plugin, query, token) : - await PluginManager.QueryForPluginAsync(plugin, query, token); + IReadOnlyList results = currentIsQuickSwitch ? + await PluginManager.QueryQuickSwitchForPluginAsync(plugin, query, token) : + currentIsHomeQuery ? + await PluginManager.QueryHomeForPluginAsync(plugin, query, token) : + await PluginManager.QueryForPluginAsync(plugin, query, token); if (token.IsCancellationRequested) return; @@ -1417,7 +1490,7 @@ await PluginManager.QueryHomeForPluginAsync(plugin, query, token) : else { // make a copy of results to avoid possible issue that FL changes some properties of the records, like score, etc. - resultsCopy = DeepCloneResults(results, token); + resultsCopy = DeepCloneResults(results, currentIsQuickSwitch, token); } foreach (var result in resultsCopy) @@ -1722,6 +1795,197 @@ public bool ShouldIgnoreHotkeys() #endregion + #region Quick Switch + + public nint DialogWindowHandle { get; private set; } = nint.Zero; + + private bool _isQuickSwitch = false; + + private bool _previousMainWindowVisibilityStatus; + + private CancellationTokenSource _quickSwitchSource; + + public void InitializeVisibilityStatus(bool visibilityStatus) + { + _previousMainWindowVisibilityStatus = visibilityStatus; + } + + public bool IsQuickSwitchWindowUnderDialog() + { + return _isQuickSwitch && QuickSwitch.QuickSwitchWindowPosition == QuickSwitchWindowPositions.UnderDialog; + } + + public async Task SetupQuickSwitchAsync(nint handle) + { + if (handle == nint.Zero) return; + + // Only set flag & reset window once for one file dialog + var dialogWindowHandleChanged = false; + if (DialogWindowHandle != handle) + { + DialogWindowHandle = handle; + _previousMainWindowVisibilityStatus = MainWindowVisibilityStatus; + _isQuickSwitch = true; + + dialogWindowHandleChanged = true; + + // If don't give a time, Positioning will be weird + await Task.Delay(300); + } + + // If handle is cleared, which means the dialog is closed, do nothing + if (DialogWindowHandle == nint.Zero) return; + + // Initialize quick switch window + if (MainWindowVisibilityStatus) + { + if (dialogWindowHandleChanged) + { + // Only update the position + Application.Current?.Dispatcher.Invoke(() => + { + (Application.Current?.MainWindow as MainWindow).UpdatePosition(); + }); + + _ = ResetWindowAsync(); + } + } + else + { + if (QuickSwitch.QuickSwitchWindowPosition == QuickSwitchWindowPositions.UnderDialog) + { + Show(); + + if (dialogWindowHandleChanged) + { + _ = ResetWindowAsync(); + } + } + else + { + if (dialogWindowHandleChanged) + { + _ = ResetWindowAsync(); + } + } + } + + if (QuickSwitch.QuickSwitchWindowPosition == QuickSwitchWindowPositions.UnderDialog) + { + // Cancel the previous quick switch task + _quickSwitchSource?.Cancel(); + + // Create a new cancellation token source + _quickSwitchSource = new CancellationTokenSource(); + + _ = Task.Run(() => + { + try + { + // Check task cancellation + if (_quickSwitchSource.Token.IsCancellationRequested) return; + + // Check dialog handle + if (DialogWindowHandle == nint.Zero) return; + + // Wait 150ms to check if quick switch window gets the focus + var timeOut = !SpinWait.SpinUntil(() => !Win32Helper.IsForegroundWindow(DialogWindowHandle), 150); + if (timeOut) return; + + // Bring focus back to the the dialog + Win32Helper.SetForegroundWindow(DialogWindowHandle); + } + catch (Exception e) + { + App.API.LogException(ClassName, "Failed to focus on dialog window", e); + } + }); + } + } + +#pragma warning disable VSTHRD100 // Avoid async void methods + + public async void ResetQuickSwitch() + { + if (DialogWindowHandle == nint.Zero) return; + + DialogWindowHandle = nint.Zero; + _isQuickSwitch = false; + + if (_previousMainWindowVisibilityStatus != MainWindowVisibilityStatus) + { + // Show or hide to change visibility + if (_previousMainWindowVisibilityStatus) + { + Show(); + + _ = ResetWindowAsync(); + } + else + { + await ResetWindowAsync(); + + Hide(false); + } + } + else + { + if (_previousMainWindowVisibilityStatus) + { + // Only update the position + Application.Current?.Dispatcher.Invoke(() => + { + (Application.Current?.MainWindow as MainWindow).UpdatePosition(); + }); + + _ = ResetWindowAsync(); + } + else + { + _ = ResetWindowAsync(); + } + } + } + +#pragma warning restore VSTHRD100 // Avoid async void methods + + public void HideQuickSwitch() + { + if (DialogWindowHandle != nint.Zero) + { + if (QuickSwitch.QuickSwitchWindowPosition == QuickSwitchWindowPositions.UnderDialog) + { + // Warning: Main window is already in foreground + // This is because if you click popup menus in other applications to hide quick switch window, + // they can steal focus before showing main window + if (MainWindowVisibilityStatus) + { + Hide(); + } + } + } + } + + // Reset index & preview & selected results & query text + private async Task ResetWindowAsync() + { + lastHistoryIndex = 1; + + if (ExternalPreviewVisible) + { + await CloseExternalPreviewAsync(); + } + + if (!QueryResultsSelected()) + { + SelectedResults = Results; + } + + await ChangeQueryTextAsync(string.Empty, true); + } + + #endregion + #region Public Methods #pragma warning disable VSTHRD100 // Avoid async void methods @@ -1741,7 +2005,7 @@ public void Show() Win32Helper.DWMSetCloakForWindow(mainWindow, false); // Set clock and search icon opacity - var opacity = Settings.UseAnimation ? 0.0 : 1.0; + var opacity = (Settings.UseAnimation && !_isQuickSwitch) ? 0.0 : 1.0; ClockPanelOpacity = opacity; SearchIconOpacity = opacity; @@ -1770,37 +2034,40 @@ public void Show() } } - public async void Hide() + public async void Hide(bool reset = true) { - lastHistoryIndex = 1; - - if (ExternalPreviewVisible) + if (reset) { - await CloseExternalPreviewAsync(); - } + lastHistoryIndex = 1; - BackToQueryResults(); + if (ExternalPreviewVisible) + { + await CloseExternalPreviewAsync(); + } - switch (Settings.LastQueryMode) - { - case LastQueryMode.Empty: - await ChangeQueryTextAsync(string.Empty); - break; - case LastQueryMode.Preserved: - case LastQueryMode.Selected: - LastQuerySelected = Settings.LastQueryMode == LastQueryMode.Preserved; - break; - case LastQueryMode.ActionKeywordPreserved: - case LastQueryMode.ActionKeywordSelected: - var newQuery = _lastQuery?.ActionKeyword; + BackToQueryResults(); + + switch (Settings.LastQueryMode) + { + case LastQueryMode.Empty: + await ChangeQueryTextAsync(string.Empty); + break; + case LastQueryMode.Preserved: + case LastQueryMode.Selected: + LastQuerySelected = Settings.LastQueryMode == LastQueryMode.Preserved; + break; + case LastQueryMode.ActionKeywordPreserved: + case LastQueryMode.ActionKeywordSelected: + var newQuery = _lastQuery.ActionKeyword; - if (!string.IsNullOrEmpty(newQuery)) - newQuery += " "; - await ChangeQueryTextAsync(newQuery); + if (!string.IsNullOrEmpty(newQuery)) + newQuery += " "; + await ChangeQueryTextAsync(newQuery); - if (Settings.LastQueryMode == LastQueryMode.ActionKeywordSelected) - LastQuerySelected = false; - break; + if (Settings.LastQueryMode == LastQueryMode.ActionKeywordSelected) + LastQuerySelected = false; + break; + } } // When application is exiting, the Application.Current will be null @@ -1810,7 +2077,7 @@ public async void Hide() if (Application.Current?.MainWindow is MainWindow mainWindow) { // Set clock and search icon opacity - var opacity = Settings.UseAnimation ? 0.0 : 1.0; + var opacity = (Settings.UseAnimation && !_isQuickSwitch) ? 0.0 : 1.0; ClockPanelOpacity = opacity; SearchIconOpacity = opacity; @@ -1939,6 +2206,7 @@ protected virtual void Dispose(bool disposing) if (disposing) { _updateSource?.Dispose(); + _quickSwitchSource?.Dispose(); _resultsUpdateChannelWriter?.Complete(); if (_resultsViewUpdateTask?.IsCompleted == true) { diff --git a/Plugins/Flow.Launcher.Plugin.Explorer/Main.cs b/Plugins/Flow.Launcher.Plugin.Explorer/Main.cs index 1c5d074a0b8..1b81edec6cc 100644 --- a/Plugins/Flow.Launcher.Plugin.Explorer/Main.cs +++ b/Plugins/Flow.Launcher.Plugin.Explorer/Main.cs @@ -10,10 +10,11 @@ using System.Threading.Tasks; using System.Windows.Controls; using Flow.Launcher.Plugin.Explorer.Exceptions; +using System.Linq; namespace Flow.Launcher.Plugin.Explorer { - public class Main : ISettingProvider, IAsyncPlugin, IContextMenu, IPluginI18n + public class Main : ISettingProvider, IAsyncPlugin, IContextMenu, IPluginI18n, IAsyncQuickSwitch { internal static PluginInitContext Context { get; set; } @@ -25,6 +26,8 @@ public class Main : ISettingProvider, IAsyncPlugin, IContextMenu, IPluginI18n private SearchManager searchManager; + private static readonly List _emptyQuickSwitchResultList = new(); + public Control CreateSettingPanel() { return new ExplorerSettings(viewModel); @@ -95,5 +98,18 @@ public string GetTranslatedPluginDescription() { return Context.API.GetTranslation("plugin_explorer_plugin_description"); } + + public async Task> QueryQuickSwitchAsync(Query query, CancellationToken token) + { + try + { + var results = await searchManager.SearchAsync(query, token); + return results.Select(r => QuickSwitchResult.From(r, r.CopyText)).ToList(); + } + catch (Exception e) when (e is SearchException or EngineNotAvailableException) + { + return _emptyQuickSwitchResultList; + } + } } } diff --git a/Plugins/Flow.Launcher.Plugin.Explorer/Search/ResultManager.cs b/Plugins/Flow.Launcher.Plugin.Explorer/Search/ResultManager.cs index 5c4accdc05f..b7991d28ef2 100644 --- a/Plugins/Flow.Launcher.Plugin.Explorer/Search/ResultManager.cs +++ b/Plugins/Flow.Launcher.Plugin.Explorer/Search/ResultManager.cs @@ -266,16 +266,16 @@ internal static Result CreateOpenCurrentFolderResult(string path, string actionK internal static Result CreateFileResult(string filePath, Query query, int score = 0, bool windowsIndexed = false) { - bool isMedia = IsMedia(Path.GetExtension(filePath)); + var isMedia = IsMedia(Path.GetExtension(filePath)); var title = Path.GetFileName(filePath); - + var directory = Path.GetDirectoryName(filePath); /* Preview Detail */ var result = new Result { Title = title, - SubTitle = Path.GetDirectoryName(filePath), + SubTitle = directory, IcoPath = filePath, Preview = new Result.PreviewInfo { @@ -299,7 +299,7 @@ internal static Result CreateFileResult(string filePath, Query query, int score { if (c.SpecialKeyState.ToModifierKeys() == (ModifierKeys.Control | ModifierKeys.Shift)) { - OpenFile(filePath, Settings.UseLocationAsWorkingDir ? Path.GetDirectoryName(filePath) : string.Empty, true); + OpenFile(filePath, Settings.UseLocationAsWorkingDir ? directory : string.Empty, true); } else if (c.SpecialKeyState.ToModifierKeys() == ModifierKeys.Control) { @@ -307,7 +307,7 @@ internal static Result CreateFileResult(string filePath, Query query, int score } else { - OpenFile(filePath, Settings.UseLocationAsWorkingDir ? Path.GetDirectoryName(filePath) : string.Empty); + OpenFile(filePath, Settings.UseLocationAsWorkingDir ? directory : string.Empty); } } catch (Exception ex)