diff --git a/Flow.Launcher.Core/Plugin/PluginManager.cs b/Flow.Launcher.Core/Plugin/PluginManager.cs index d88f2f050e5..c9a0cb026b0 100644 --- a/Flow.Launcher.Core/Plugin/PluginManager.cs +++ b/Flow.Launcher.Core/Plugin/PluginManager.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; @@ -9,6 +9,7 @@ using CommunityToolkit.Mvvm.DependencyInjection; using Flow.Launcher.Core.ExternalPlugins; using Flow.Launcher.Infrastructure; +using Flow.Launcher.Infrastructure.DialogJump; using Flow.Launcher.Infrastructure.UserSettings; using Flow.Launcher.Plugin; using Flow.Launcher.Plugin.SharedCommands; @@ -40,6 +41,9 @@ public static class PluginManager private static IEnumerable _resultUpdatePlugin; private static IEnumerable _translationPlugins; + private static readonly List _dialogJumpExplorerPlugins = new(); + private static readonly List _dialogJumpDialogPlugins = new(); + /// /// Directories that will hold Flow Launcher plugin directory /// @@ -186,6 +190,24 @@ public static void LoadPlugins(PluginsSettings settings) _homePlugins = GetPluginsForInterface(); _resultUpdatePlugin = GetPluginsForInterface(); _translationPlugins = GetPluginsForInterface(); + + // Initialize Dialog Jump plugin pairs + foreach (var pair in GetPluginsForInterface()) + { + _dialogJumpExplorerPlugins.Add(new DialogJumpExplorerPair + { + Plugin = (IDialogJumpExplorer)pair.Plugin, + Metadata = pair.Metadata + }); + } + foreach (var pair in GetPluginsForInterface()) + { + _dialogJumpDialogPlugins.Add(new DialogJumpDialogPair + { + Plugin = (IDialogJumpDialog)pair.Plugin, + Metadata = pair.Metadata + }); + } } private static void UpdatePluginDirectory(List metadatas) @@ -288,20 +310,24 @@ public static async Task InitializePluginsAsync() } } - public static ICollection ValidPluginsForQuery(Query query) + public static ICollection ValidPluginsForQuery(Query query, bool dialogJump) { if (query is null) return Array.Empty(); if (!NonGlobalPlugins.TryGetValue(query.ActionKeyword, out var plugin)) { - return GlobalPlugins.Where(p => !PluginModified(p.Metadata.ID)).ToList(); + if (dialogJump) + return GlobalPlugins.Where(p => p.Plugin is IAsyncDialogJump && !PluginModified(p.Metadata.ID)).ToList(); + else + return GlobalPlugins.Where(p => !PluginModified(p.Metadata.ID)).ToList(); } + if (dialogJump && plugin.Plugin is not IAsyncDialogJump) + return Array.Empty(); + if (API.PluginModified(plugin.Metadata.ID)) - { return Array.Empty(); - } return new List { @@ -388,6 +414,36 @@ public static async Task> QueryHomeForPluginAsync(PluginPair pair, return results; } + public static async Task> QueryDialogJumpForPluginAsync(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 ((IAsyncDialogJump)pair.Plugin).QueryDialogJumpAsync(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 Dialog Jump for plugin: {metadata.Name}", e); + return null; + } + return results; + } + public static void UpdatePluginMetadata(IReadOnlyList results, PluginMetadata metadata, Query query) { foreach (var r in results) @@ -463,6 +519,16 @@ public static bool IsHomePlugin(string id) return _homePlugins.Where(p => !PluginModified(p.Metadata.ID)).Any(p => p.Metadata.ID == id); } + public static IList GetDialogJumpExplorers() + { + return _dialogJumpExplorerPlugins.Where(p => !PluginModified(p.Metadata.ID)).ToList(); + } + + public static IList GetDialogJumpDialogs() + { + return _dialogJumpDialogPlugins.Where(p => !PluginModified(p.Metadata.ID)).ToList(); + } + public static bool ActionKeywordRegistered(string actionKeyword) { // this method is only checking for action keywords (defined as not '*') registration diff --git a/Flow.Launcher.Infrastructure/DialogJump/DialogJump.cs b/Flow.Launcher.Infrastructure/DialogJump/DialogJump.cs new file mode 100644 index 00000000000..65652878fc8 --- /dev/null +++ b/Flow.Launcher.Infrastructure/DialogJump/DialogJump.cs @@ -0,0 +1,1079 @@ +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.DialogJump.Models; +using Flow.Launcher.Infrastructure.UserSettings; +using Flow.Launcher.Plugin; +using NHotkey; +using Windows.Win32; +using Windows.Win32.Foundation; +using Windows.Win32.UI.Accessibility; + +namespace Flow.Launcher.Infrastructure.DialogJump +{ + public static class DialogJump + { + #region Public Properties + + public static Func ShowDialogJumpWindowAsync { get; set; } = null; + + public static Action UpdateDialogJumpWindow { get; set; } = null; + + public static Action ResetDialogJumpWindow { get; set; } = null; + + public static Action HideDialogJumpWindow { get; set; } = null; + + public static DialogJumpWindowPositions DialogJumpWindowPosition { get; private set; } + + public static DialogJumpExplorerPair WindowsDialogJumpExplorer { get; } = new() + { + Metadata = new() + { + ID = "298b197c08a24e90ab66ac060ee2b6b8", // ID is for calculating the hash id of the Dialog Jump pairs + Disabled = false // Disabled is for enabling the Windows DialogJump explorers & dialogs + }, + Plugin = new WindowsExplorer() + }; + + public static DialogJumpDialogPair WindowsDialogJumpDialog { get; } = new() + { + Metadata = new() + { + ID = "a4a113dc51094077ab4abb391e866c7b", // ID is for calculating the hash id of the Dialog Jump pairs + Disabled = false // Disabled is for enabling the Windows DialogJump explorers & dialogs + }, + Plugin = new WindowsDialog() + }; + + #endregion + + #region Private Fields + + private static readonly string ClassName = nameof(DialogJump); + + private static readonly Settings _settings = Ioc.Default.GetRequiredService(); + + // We should not initialize API in static constructor because it will create another API instance + private static IPublicAPI api = null; + private static IPublicAPI API => api ??= Ioc.Default.GetRequiredService(); + + private static HWND _mainWindowHandle = HWND.Null; + + private static readonly Dictionary _dialogJumpExplorers = new(); + + private static DialogJumpExplorerPair _lastExplorer = null; + private static readonly object _lastExplorerLock = new(); + + private static readonly Dictionary _dialogJumpDialogs = new(); + + private static IDialogJumpDialogWindow _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 HWINEVENTHOOK _hideChangeHook = HWINEVENTHOOK.Null; + private static HWINEVENTHOOK _dialogEndChangeHook = HWINEVENTHOOK.Null; + + private static readonly WINEVENTPROC _fgProc = ForegroundChangeCallback; + private static readonly WINEVENTPROC _locProc = LocationChangeCallback; + private static readonly WINEVENTPROC _desProc = DestroyChangeCallback; + private static readonly WINEVENTPROC _hideProc = HideChangeCallback; + private static readonly WINEVENTPROC _dialogEndProc = DialogEndChangeCallback; + + 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 InitializeDialogJump(IList dialogJumpExplorers, + IList dialogJumpDialogs) + { + if (_initialized) return; + + // Initialize Dialog Jump explorers & dialogs + _dialogJumpExplorers.Add(WindowsDialogJumpExplorer, null); + foreach (var explorer in dialogJumpExplorers) + { + _dialogJumpExplorers.Add(explorer, null); + } + _dialogJumpDialogs.Add(WindowsDialogJumpDialog, null); + foreach (var dialog in dialogJumpDialogs) + { + _dialogJumpDialogs.Add(dialog, null); + } + + // Initialize main window handle + _mainWindowHandle = Win32Helper.GetMainWindowHandle(); + + // Initialize timer + _dragMoveTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(10) }; + _dragMoveTimer.Tick += (s, e) => InvokeUpdateDialogJumpWindow(); + + // Initialize Dialog Jump window position + DialogJumpWindowPosition = _settings.DialogJumpWindowPosition; + + _initialized = true; + } + + public static void SetupDialogJump(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; + } + if (!_hideChangeHook.IsNull) + { + PInvoke.UnhookWinEvent(_hideChangeHook); + _hideChangeHook = HWINEVENTHOOK.Null; + } + if (!_dialogEndChangeHook.IsNull) + { + PInvoke.UnhookWinEvent(_dialogEndChangeHook); + _dialogEndChangeHook = 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); + _hideChangeHook = PInvoke.SetWinEventHook( + PInvoke.EVENT_OBJECT_HIDE, + PInvoke.EVENT_OBJECT_HIDE, + PInvoke.GetModuleHandle((PCWSTR)null), + _hideProc, + 0, + 0, + PInvoke.WINEVENT_OUTOFCONTEXT); + _dialogEndChangeHook = PInvoke.SetWinEventHook( + PInvoke.EVENT_SYSTEM_DIALOGEND, + PInvoke.EVENT_SYSTEM_DIALOGEND, + PInvoke.GetModuleHandle((PCWSTR)null), + _dialogEndProc, + 0, + 0, + PInvoke.WINEVENT_OUTOFCONTEXT); + + if (_foregroundChangeHook.IsNull || + _locationChangeHook.IsNull || + _destroyChangeHook.IsNull || + _hideChangeHook.IsNull || + _dialogEndChangeHook.IsNull) + { + Log.Error(ClassName, "Failed to enable DialogJump"); + return; + } + } + else + { + // Remove explorer windows + foreach (var explorer in _dialogJumpExplorers.Keys) + { + _dialogJumpExplorers[explorer] = null; + } + + // Remove dialog windows + foreach (var dialog in _dialogJumpDialogs.Keys) + { + _dialogJumpDialogs[dialog] = null; + } + + // Remove dialog window handle + var dialogWindowExists = false; + lock (_dialogWindowLock) + { + if (_dialogWindow != null) + { + _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; + } + if (!_hideChangeHook.IsNull) + { + PInvoke.UnhookWinEvent(_hideChangeHook); + _hideChangeHook = HWINEVENTHOOK.Null; + } + if (!_dialogEndChangeHook.IsNull) + { + PInvoke.UnhookWinEvent(_dialogEndChangeHook); + _dialogEndChangeHook = HWINEVENTHOOK.Null; + } + + // Stop drag move timer + _dragMoveTimer?.Stop(); + + // Reset Dialog Jump window + if (dialogWindowExists) + { + InvokeResetDialogJumpWindow(); + } + } + + _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 _dialogJumpExplorers.Keys) + { + if (API.PluginModified(explorer.Metadata.ID) || // Plugin is modified + explorer.Metadata.Disabled) continue; // Plugin is disabled + + var explorerWindow = explorer.Plugin.CheckExplorerWindow(hWnd); + if (explorerWindow != null) + { + _dialogJumpExplorers[explorer] = explorerWindow; + _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() ? _dialogJumpExplorers[_lastExplorer].GetExplorerPath() : string.Empty; + } + + #endregion + + #region Events + + #region Invoke Property Events + + private static async Task InvokeShowDialogJumpWindowAsync(bool dialogWindowChanged) + { + // Show Dialog Jump window + if (_settings.ShowDialogJumpWindow) + { + // Save Dialog Jump window position for one file dialog + if (dialogWindowChanged) + { + DialogJumpWindowPosition = _settings.DialogJumpWindowPosition; + } + + // Call show Dialog Jump window + IDialogJumpDialogWindow dialogWindow; + lock (_dialogWindowLock) + { + dialogWindow = _dialogWindow; + } + if (dialogWindow != null && ShowDialogJumpWindowAsync != null) + { + await ShowDialogJumpWindowAsync.Invoke(dialogWindow.Handle); + } + + // Hook move size event if Dialog Jump window is under dialog & dialog window changed + if (DialogJumpWindowPosition == DialogJumpWindowPositions.UnderDialog) + { + if (dialogWindowChanged) + { + HWND dialogWindowHandle = HWND.Null; + lock (_dialogWindowLock) + { + if (_dialogWindow != null) + { + dialogWindowHandle = new(_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 InvokeUpdateDialogJumpWindow() + { + UpdateDialogJumpWindow?.Invoke(); + } + + private static void InvokeResetDialogJumpWindow() + { + lock (_dialogWindowLock) + { + _dialogWindow = null; + } + + // Reset Dialog Jump window + ResetDialogJumpWindow?.Invoke(); + + // Stop drag move timer + _dragMoveTimer?.Stop(); + + // Unhook move size event + if (!_moveSizeHook.IsNull) + { + PInvoke.UnhookWinEvent(_moveSizeHook); + _moveSizeHook = HWINEVENTHOOK.Null; + } + } + + private static void InvokeHideDialogJumpWindow() + { + // Hide Dialog Jump window + HideDialogJumpWindow?.Invoke(); + + // Stop drag move timer + _dragMoveTimer?.Stop(); + } + + #endregion + + #region Hotkey + + public static void OnToggleHotkey(object sender, HotkeyEventArgs args) + { + _ = Task.Run(async () => + { + try + { + await NavigateDialogPathAsync(PInvoke.GetForegroundWindow()); + } + catch (System.Exception ex) + { + Log.Exception(ClassName, "Failed to navigate dialog path", ex); + } + }); + } + + #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 _dialogJumpDialogs.Keys) + { + if (API.PluginModified(dialog.Metadata.ID) || // Plugin is modified + dialog.Metadata.Disabled) continue; // Plugin is disabled + + IDialogJumpDialogWindow dialogWindow; + var existingDialogWindow = _dialogJumpDialogs[dialog]; + if (existingDialogWindow != null && existingDialogWindow.Handle == hwnd) + { + // If the dialog window is already in the list, no need to check again + dialogWindow = existingDialogWindow; + } + else + { + dialogWindow = dialog.Plugin.CheckDialogWindow(hwnd); + } + + // If the dialog window is found, set it + if (dialogWindow != null) + { + lock (_dialogWindowLock) + { + dialogWindowChanged = _dialogWindow == null || _dialogWindow.Handle != hwnd; + _dialogWindow = dialogWindow; + } + + isDialogWindow = true; + break; + } + } + + // Handle window based on its type + if (isDialogWindow) + { + Log.Debug(ClassName, $"Dialog Window: {hwnd}"); + // Navigate to path + if (_settings.AutoDialogJump) + { + // Check if we have already switched for this dialog + bool alreadySwitched; + lock (_autoSwitchedDialogsLock) + { + alreadySwitched = _autoSwitchedDialogs.Contains(hwnd); + } + + // Just show Dialog Jump window + if (alreadySwitched) + { + await InvokeShowDialogJumpWindowAsync(dialogWindowChanged); + } + // Show Dialog Jump window after navigating the path + else + { + if (!await Task.Run(async () => + { + try + { + return await NavigateDialogPathAsync(hwnd, true); + } + catch (System.Exception ex) + { + Log.Exception(ClassName, "Failed to navigate dialog path", ex); + return false; + } + })) + { + await InvokeShowDialogJumpWindowAsync(dialogWindowChanged); + } + } + } + else + { + await InvokeShowDialogJumpWindowAsync(dialogWindowChanged); + } + } + // Dialog jump 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 Dialog Jump window nor file dialog window is foreground + { + // Hide Dialog Jump window until the file dialog window is brought to the foreground + InvokeHideDialogJumpWindow(); + } + + // Check if there are foreground explorer windows + try + { + lock (_lastExplorerLock) + { + foreach (var explorer in _dialogJumpExplorers.Keys) + { + if (API.PluginModified(explorer.Metadata.ID) || // Plugin is modified + explorer.Metadata.Disabled) continue; // Plugin is disabled + + var explorerWindow = explorer.Plugin.CheckExplorerWindow(hwnd); + if (explorerWindow != null) + { + Log.Debug(ClassName, $"Explorer window: {hwnd}"); + _dialogJumpExplorers[explorer] = explorerWindow; + _lastExplorer = explorer; + break; + } + } + } + } + catch (System.Exception ex) + { + Log.Exception(ClassName, "An error occurred while checking foreground explorer windows", ex); + } + } + } + catch (System.Exception ex) + { + Log.Exception(ClassName, "Failed to invoke ForegroundChangeCallback", ex); + } + 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 Dialog Jump window position + var dialogWindowExist = false; + lock (_dialogWindowLock) + { + if (_dialogWindow != null && _dialogWindow.Handle == hwnd) + { + dialogWindowExist = true; + } + } + if (dialogWindowExist) + { + InvokeUpdateDialogJumpWindow(); + } + } + + 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 Dialog Jump 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); + } + InvokeResetDialogJumpWindow(); + } + } + + private static void HideChangeCallback( + HWINEVENTHOOK hWinEventHook, + uint eventType, + HWND hwnd, + int idObject, + int idChild, + uint dwEventThread, + uint dwmsEventTime + ) + { + // If the dialog window is hidden, set _dialogWindowHandle to null + var dialogWindowExist = false; + lock (_dialogWindowLock) + { + if (_dialogWindow != null && _dialogWindow.Handle == hwnd) + { + Log.Debug(ClassName, $"Hide dialog: {hwnd}"); + _dialogWindow = null; + dialogWindowExist = true; + } + } + if (dialogWindowExist) + { + lock (_autoSwitchedDialogsLock) + { + _autoSwitchedDialogs.Remove(hwnd); + } + InvokeResetDialogJumpWindow(); + } + } + + private static void DialogEndChangeCallback( + HWINEVENTHOOK hWinEventHook, + uint eventType, + HWND hwnd, + int idObject, + int idChild, + uint dwEventThread, + uint dwmsEventTime + ) + { + // If the dialog window is ended, set _dialogWindowHandle to null + var dialogWindowExist = false; + lock (_dialogWindowLock) + { + if (_dialogWindow != null && _dialogWindow.Handle == hwnd) + { + Log.Debug(ClassName, $"End dialog: {hwnd}"); + _dialogWindow = null; + dialogWindowExist = true; + } + } + if (dialogWindowExist) + { + lock (_autoSwitchedDialogsLock) + { + _autoSwitchedDialogs.Remove(hwnd); + } + InvokeResetDialogJumpWindow(); + } + } + + #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 null or empty + if (string.IsNullOrEmpty(path)) 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 = _dialogJumpExplorers[_lastExplorer]?.GetExplorerPath(); + } + + // Check path null or empty + 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; + try + { + // shell: and shell::: paths + if (path.StartsWith("shell:", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + // file: URI paths + var localPath = path.StartsWith("file:", StringComparison.OrdinalIgnoreCase) + ? new Uri(path).LocalPath + : path; + // Is folder? + var isFolder = Directory.Exists(localPath); + // Is file? + var isFile = File.Exists(localPath); + file = isFile; + return isFolder || isFile; + } + catch (System.Exception e) + { + Log.Exception(ClassName, "Failed to check path", e); + return false; + } + } + + private static IDialogJumpDialogWindowTab GetDialogWindowTab(HWND hwnd) + { + var dialogWindow = GetDialogWindow(hwnd); + if (dialogWindow == null) return null; + var dialogWindowTab = dialogWindow.GetCurrentTab(); + return dialogWindowTab; + } + + private static IDialogJumpDialogWindow 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 _dialogJumpDialogs.Keys) + { + if (API.PluginModified(dialog.Metadata.ID) || // Plugin is modified + dialog.Metadata.Disabled) continue; // Plugin is disabled + + var dialogWindow = _dialogJumpDialogs[dialog]; + if (dialogWindow != null && dialogWindow.Handle == hwnd) + { + return dialogWindow; + } + } + + // Finally search for the dialog window again + foreach (var dialog in _dialogJumpDialogs.Keys) + { + if (API.PluginModified(dialog.Metadata.ID) || // Plugin is modified + dialog.Metadata.Disabled) continue; // Plugin is disabled + + IDialogJumpDialogWindow dialogWindow; + var existingDialogWindow = _dialogJumpDialogs[dialog]; + if (existingDialogWindow != null && existingDialogWindow.Handle == hwnd) + { + // If the dialog window is already in the list, no need to check again + dialogWindow = existingDialogWindow; + } + else + { + dialogWindow = dialog.Plugin.CheckDialogWindow(hwnd); + } + + // Update dialog window if found + if (dialogWindow != null) + { + _dialogJumpDialogs[dialog] = dialogWindow; + return dialogWindow; + } + } + + return null; + } + + private static async Task JumpToPathAsync(IDialogJumpDialogWindowTab 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.DialogJumpFileResultBehaviour) + { + case DialogJumpFileResultBehaviours.FullPath: + Log.Debug(ClassName, $"File Jump FullPath: {path}"); + result = FileJump(path, dialog); + break; + case DialogJumpFileResultBehaviours.FullPathOpen: + Log.Debug(ClassName, $"File Jump FullPathOpen: {path}"); + result = FileJump(path, dialog, openFile: true); + break; + case DialogJumpFileResultBehaviours.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(new(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, IDialogJumpDialogWindowTab 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, IDialogJumpDialogWindowTab 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; + } + if (!_hideChangeHook.IsNull) + { + PInvoke.UnhookWinEvent(_hideChangeHook); + _hideChangeHook = HWINEVENTHOOK.Null; + } + if (!_dialogEndChangeHook.IsNull) + { + PInvoke.UnhookWinEvent(_dialogEndChangeHook); + _dialogEndChangeHook = HWINEVENTHOOK.Null; + } + + // Dispose explorers + foreach (var explorer in _dialogJumpExplorers.Keys) + { + _dialogJumpExplorers[explorer]?.Dispose(); + } + _dialogJumpExplorers.Clear(); + lock (_lastExplorerLock) + { + _lastExplorer = null; + } + + // Dispose dialogs + foreach (var dialog in _dialogJumpDialogs.Keys) + { + _dialogJumpDialogs[dialog]?.Dispose(); + } + _dialogJumpDialogs.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/DialogJump/DialogJumpPair.cs b/Flow.Launcher.Infrastructure/DialogJump/DialogJumpPair.cs new file mode 100644 index 00000000000..d1248eac13d --- /dev/null +++ b/Flow.Launcher.Infrastructure/DialogJump/DialogJumpPair.cs @@ -0,0 +1,63 @@ +using Flow.Launcher.Plugin; + +namespace Flow.Launcher.Infrastructure.DialogJump; + +public class DialogJumpExplorerPair +{ + public IDialogJumpExplorer Plugin { get; init; } + + public PluginMetadata Metadata { get; init; } + + public override string ToString() + { + return Metadata.Name; + } + + public override bool Equals(object obj) + { + if (obj is DialogJumpExplorerPair r) + { + return string.Equals(r.Metadata.ID, Metadata.ID); + } + else + { + return false; + } + } + + public override int GetHashCode() + { + var hashcode = Metadata.ID?.GetHashCode() ?? 0; + return hashcode; + } +} + +public class DialogJumpDialogPair +{ + public IDialogJumpDialog Plugin { get; init; } + + public PluginMetadata Metadata { get; init; } + + public override string ToString() + { + return Metadata.Name; + } + + public override bool Equals(object obj) + { + if (obj is DialogJumpDialogPair r) + { + return string.Equals(r.Metadata.ID, Metadata.ID); + } + else + { + return false; + } + } + + public override int GetHashCode() + { + var hashcode = Metadata.ID?.GetHashCode() ?? 0; + return hashcode; + } +} diff --git a/Flow.Launcher.Infrastructure/DialogJump/Models/WindowsDialog.cs b/Flow.Launcher.Infrastructure/DialogJump/Models/WindowsDialog.cs new file mode 100644 index 00000000000..ee4e034337b --- /dev/null +++ b/Flow.Launcher.Infrastructure/DialogJump/Models/WindowsDialog.cs @@ -0,0 +1,345 @@ +using System; +using System.Threading; +using Flow.Launcher.Infrastructure.Logger; +using Flow.Launcher.Plugin; +using Windows.Win32; +using Windows.Win32.Foundation; +using Windows.Win32.UI.WindowsAndMessaging; +using WindowsInput; +using WindowsInput.Native; + +namespace Flow.Launcher.Infrastructure.DialogJump.Models +{ + /// + /// Class for handling Windows File Dialog instances in DialogJump. + /// + public class WindowsDialog : IDialogJumpDialog + { + private const string WindowsDialogClassName = "#32770"; + + public IDialogJumpDialogWindow CheckDialogWindow(IntPtr hwnd) + { + // Is it a Win32 dialog box? + if (GetClassName(new(hwnd)) == WindowsDialogClassName) + { + // Is it a windows file dialog? + var dialogType = GetFileDialogType(new(hwnd)); + if (dialogType != DialogType.Others) + { + return new WindowsDialogWindow(hwnd, dialogType); + } + } + + return null; + } + + public void Dispose() + { + + } + + #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 + } + + public class WindowsDialogWindow : IDialogJumpDialogWindow + { + public IntPtr Handle { get; private set; } = IntPtr.Zero; + + // 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 IDialogJumpDialogWindowTab _currentTab { get; set; } = null; + + private readonly DialogType _dialogType; + + internal WindowsDialogWindow(IntPtr handle, DialogType dialogType) + { + Handle = handle; + _dialogType = dialogType; + } + + public IDialogJumpDialogWindowTab GetCurrentTab() + { + return _currentTab ??= new WindowsDialogTab(Handle, _dialogType); + } + + public void Dispose() + { + + } + } + + public class WindowsDialogTab : IDialogJumpDialogWindowTab + { + #region Public Properties + + public IntPtr Handle { get; private set; } = IntPtr.Zero; + + #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 + + internal WindowsDialogTab(IntPtr 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 Dialog Jump 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.GetWindowLongPtr(_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() + { + + } + + #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(new(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(new(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(new(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(new(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/DialogJump/Models/WindowsExplorer.cs b/Flow.Launcher.Infrastructure/DialogJump/Models/WindowsExplorer.cs new file mode 100644 index 00000000000..e9ed9dae7a5 --- /dev/null +++ b/Flow.Launcher.Infrastructure/DialogJump/Models/WindowsExplorer.cs @@ -0,0 +1,260 @@ +using System; +using System.Runtime.InteropServices; +using System.Threading; +using Flow.Launcher.Plugin; +using Windows.Win32; +using Windows.Win32.Foundation; +using Windows.Win32.System.Com; +using Windows.Win32.UI.Shell; + +namespace Flow.Launcher.Infrastructure.DialogJump.Models +{ + /// + /// Class for handling Windows Explorer instances in DialogJump. + /// + public class WindowsExplorer : IDialogJumpExplorer + { + public IDialogJumpExplorerWindow CheckExplorerWindow(IntPtr hwnd) + { + IDialogJumpExplorerWindow explorerWindow = null; + + // Is it from Explorer? + var processName = Win32Helper.GetProcessNameFromHwnd(new(hwnd)); + if (processName.Equals("explorer.exe", StringComparison.OrdinalIgnoreCase)) + { + EnumerateShellWindows((shellWindow) => + { + try + { + if (shellWindow is not IWebBrowser2 explorer) return true; + + if (explorer.HWND != hwnd) return true; + + explorerWindow = new WindowsExplorerWindow(hwnd); + return false; + } + catch + { + // Ignored + } + + return true; + }); + } + return explorerWindow; + } + + internal 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 void Dispose() + { + + } + } + + public class WindowsExplorerWindow : IDialogJumpExplorerWindow + { + public IntPtr Handle { get; } + + private static Guid _shellBrowserGuid = typeof(IShellBrowser).GUID; + + internal WindowsExplorerWindow(IntPtr handle) + { + Handle = handle; + } + + public string GetExplorerPath() + { + if (Handle == IntPtr.Zero) return null; + + var activeTabHandle = GetActiveTabHandle(new(Handle)); + if (activeTabHandle.IsNull) return null; + + var window = GetExplorerByTabHandle(activeTabHandle); + if (window == null) return null; + + var path = GetLocation(window); + return path; + } + + public void Dispose() + { + + } + + #region Helper Methods + + // Inspired by: https://github.com/w4po/ExplorerTabUtility + + private static HWND GetActiveTabHandle(HWND windowHandle) + { + // Active tab always at the top of the z-index, so it is the first child of the ShellTabWindowClass. + var activeTab = PInvoke.FindWindowEx(windowHandle, HWND.Null, "ShellTabWindowClass", null); + return activeTab; + } + + private static IWebBrowser2 GetExplorerByTabHandle(HWND tabHandle) + { + if (tabHandle.IsNull) return null; + + IWebBrowser2 window = null; + WindowsExplorer.EnumerateShellWindows((shellWindow) => + { + try + { + return StartSTAThread(() => + { + if (shellWindow is not IWebBrowser2 explorer) return true; + + if (explorer is not IServiceProvider sp) return true; + + sp.QueryService(ref _shellBrowserGuid, ref _shellBrowserGuid, out var shellBrowser); + if (shellBrowser == null) return true; + + try + { + shellBrowser.GetWindow(out var hWnd); // Must execute in STA thread to get this hWnd + + if (hWnd == tabHandle) + { + window = explorer; + return false; + } + } + catch + { + // Ignored + } + finally + { + Marshal.ReleaseComObject(shellBrowser); + } + + return true; + }) ?? true; + } + catch + { + // Ignored + } + + return true; + }); + + return window; + } + + private static bool? StartSTAThread(Func action) + { + bool? result = null; + var thread = new Thread(() => + { + result = action(); + }) + { + IsBackground = true + }; + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + thread.Join(); + return result; + } + + private static string GetLocation(IWebBrowser2 window) + { + var path = window.LocationURL.ToString(); + if (!string.IsNullOrWhiteSpace(path)) return NormalizeLocation(path); + + // Recycle Bin, This PC, etc + if (window.Document is not IShellFolderViewDual folderView) return null; + + // Attempt to get the path from the folder view + 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; + + // Get path from the folder item + path = folderItem.Path; + } + catch + { + return null; + } + + return NormalizeLocation(path); + } + + private static string NormalizeLocation(string location) + { + if (location.IndexOf('%') > -1) + location = Environment.ExpandEnvironmentVariables(location); + + if (location.StartsWith("::", StringComparison.Ordinal)) + location = $"shell:{location}"; + + else if (location.StartsWith("{", StringComparison.Ordinal)) + location = $"shell:::{location}"; + + location = location.Trim(' ', '/', '\\', '\n', '\'', '"'); + + return location.Replace('/', '\\'); + } + + #endregion + } + + #region COM Interfaces + + // Inspired by: https://github.com/w4po/ExplorerTabUtility + + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + [Guid("6d5140c1-7436-11ce-8034-00aa006009fa")] + [ComImport] + public interface IServiceProvider + { + [PreserveSig] + int QueryService(ref Guid guidService, ref Guid riid, [MarshalAs(UnmanagedType.Interface)] out IShellBrowser ppvObject); + } + + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + [Guid("000214E2-0000-0000-C000-000000000046")] + [ComImport] + public interface IShellBrowser + { + [PreserveSig] + int GetWindow(out nint handle); + } + + #endregion +} diff --git a/Flow.Launcher.Infrastructure/Flow.Launcher.Infrastructure.csproj b/Flow.Launcher.Infrastructure/Flow.Launcher.Infrastructure.csproj index be9e4e0f9a9..390de341d25 100644 --- a/Flow.Launcher.Infrastructure/Flow.Launcher.Infrastructure.csproj +++ b/Flow.Launcher.Infrastructure/Flow.Launcher.Infrastructure.csproj @@ -1,4 +1,4 @@ - + net9.0-windows @@ -60,12 +60,14 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + all runtime; build; native; contentfiles; analyzers; buildtransitive + all diff --git a/Flow.Launcher.Infrastructure/NativeMethods.txt b/Flow.Launcher.Infrastructure/NativeMethods.txt index edc71feef24..965ab6caa8e 100644 --- a/Flow.Launcher.Infrastructure/NativeMethods.txt +++ b/Flow.Launcher.Infrastructure/NativeMethods.txt @@ -66,3 +66,27 @@ LOCALE_TRANSIENT_KEYBOARD4 SHParseDisplayName SHOpenFolderAndSelectItems CoTaskMemFree + +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 +EVENT_OBJECT_HIDE +EVENT_SYSTEM_DIALOGEND diff --git a/Flow.Launcher.Infrastructure/UserSettings/Settings.cs b/Flow.Launcher.Infrastructure/UserSettings/Settings.cs index 00ecb9bb4fd..23f9047fef7 100644 --- a/Flow.Launcher.Infrastructure/UserSettings/Settings.cs +++ b/Flow.Launcher.Infrastructure/UserSettings/Settings.cs @@ -86,6 +86,7 @@ public bool ShowOpenResultHotkey public string OpenHistoryHotkey { get; set; } = $"Ctrl+H"; public string CycleHistoryUpHotkey { get; set; } = $"{KeyConstant.Alt} + Up"; public string CycleHistoryDownHotkey { get; set; } = $"{KeyConstant.Alt} + Down"; + public string DialogJumpHotkey { get; set; } = $"{KeyConstant.Alt} + G"; private string _language = Constant.SystemLanguageCode; public string Language @@ -323,6 +324,21 @@ public CustomBrowserViewModel CustomBrowser } }; + public bool EnableDialogJump { get; set; } = true; + + public bool AutoDialogJump { get; set; } = false; + + public bool ShowDialogJumpWindow { get; set; } = false; + + [JsonConverter(typeof(JsonStringEnumConverter))] + public DialogJumpWindowPositions DialogJumpWindowPosition { get; set; } = DialogJumpWindowPositions.UnderDialog; + + [JsonConverter(typeof(JsonStringEnumConverter))] + public DialogJumpResultBehaviours DialogJumpResultBehaviour { get; set; } = DialogJumpResultBehaviours.LeftClick; + + [JsonConverter(typeof(JsonStringEnumConverter))] + public DialogJumpFileResultBehaviours DialogJumpFileResultBehaviour { get; set; } = DialogJumpFileResultBehaviours.FullPath; + [JsonConverter(typeof(JsonStringEnumConverter))] public LOGLEVEL LogLevel { get; set; } = LOGLEVEL.INFO; @@ -546,6 +562,8 @@ public List RegisteredHotkeys list.Add(new(CycleHistoryUpHotkey, "CycleHistoryUpHotkey", () => CycleHistoryUpHotkey = "")); if (!string.IsNullOrEmpty(CycleHistoryDownHotkey)) list.Add(new(CycleHistoryDownHotkey, "CycleHistoryDownHotkey", () => CycleHistoryDownHotkey = "")); + if (!string.IsNullOrEmpty(DialogJumpHotkey)) + list.Add(new(DialogJumpHotkey, "dialogJumpHotkey", () => DialogJumpHotkey = "")); // Custom Query Hotkeys foreach (var customPluginHotkey in CustomPluginHotkeys) @@ -659,4 +677,23 @@ public enum DoublePinyinSchemas DaNiu, XiaoLang } + + public enum DialogJumpWindowPositions + { + UnderDialog, + FollowDefault + } + + public enum DialogJumpResultBehaviours + { + LeftClick, + RightClick + } + + public enum DialogJumpFileResultBehaviours + { + FullPath, + FullPathOpen, + Directory + } } diff --git a/Flow.Launcher.Infrastructure/Win32Helper.cs b/Flow.Launcher.Infrastructure/Win32Helper.cs index 32ed3113738..bb1996c3b0c 100644 --- a/Flow.Launcher.Infrastructure/Win32Helper.cs +++ b/Flow.Launcher.Infrastructure/Win32Helper.cs @@ -14,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.Shell.Common; using Windows.Win32.UI.WindowsAndMessaging; @@ -138,6 +140,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()); @@ -344,6 +351,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 @@ -761,6 +778,65 @@ 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 + #region Explorer // https://learn.microsoft.com/en-us/windows/win32/api/shlobj_core/nf-shlobj_core-shopenfolderandselectitems diff --git a/Flow.Launcher.Plugin/DialogJumpResult.cs b/Flow.Launcher.Plugin/DialogJumpResult.cs new file mode 100644 index 00000000000..2c9f0c13923 --- /dev/null +++ b/Flow.Launcher.Plugin/DialogJumpResult.cs @@ -0,0 +1,92 @@ +namespace Flow.Launcher.Plugin +{ + /// + /// Describes a result of a executed by a plugin in Dialog Jump window + /// + public class DialogJumpResult : Result + { + /// + /// This holds the path which can be provided by plugin to be navigated to the + /// file dialog when records in Dialog Jump window is right clicked on a result. + /// + public required string DialogJumpPath { get; init; } + + /// + /// Clones the current Dialog Jump result + /// + public new DialogJumpResult Clone() + { + return new DialogJumpResult + { + 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, + DialogJumpPath = DialogJumpPath + }; + } + + /// + /// Convert to . + /// + public static DialogJumpResult From(Result result, string dialogJumpPath) + { + return new DialogJumpResult + { + 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, + DialogJumpPath = dialogJumpPath + }; + } + } +} diff --git a/Flow.Launcher.Plugin/Interfaces/IAsyncDialogJump.cs b/Flow.Launcher.Plugin/Interfaces/IAsyncDialogJump.cs new file mode 100644 index 00000000000..e028ebb1264 --- /dev/null +++ b/Flow.Launcher.Plugin/Interfaces/IAsyncDialogJump.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using System.Threading; + +namespace Flow.Launcher.Plugin +{ + /// + /// Asynchronous Dialog Jump Model + /// + public interface IAsyncDialogJump : IFeatures + { + /// + /// Asynchronous querying for Dialog Jump window + /// + /// + /// If the Querying method requires high IO transmission + /// or performing CPU intense jobs (performing better with cancellation), please use this IAsyncDialogJump interface + /// + /// Query to search + /// Cancel when querying job is obsolete + /// + Task> QueryDialogJumpAsync(Query query, CancellationToken token); + } +} diff --git a/Flow.Launcher.Plugin/Interfaces/IDialogJump.cs b/Flow.Launcher.Plugin/Interfaces/IDialogJump.cs new file mode 100644 index 00000000000..d81b2bd197c --- /dev/null +++ b/Flow.Launcher.Plugin/Interfaces/IDialogJump.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Flow.Launcher.Plugin +{ + /// + /// Synchronous Dialog Jump Model + /// + /// If the Querying method requires high IO transmission + /// or performing CPU intense jobs (performing better with cancellation), please try the IAsyncDialogJump interface + /// + /// + public interface IDialogJump : IAsyncDialogJump + { + /// + /// Querying for Dialog Jump window + /// + /// This method will be called within a Task.Run, + /// so please avoid synchrously wait for long. + /// + /// + /// Query to search + /// + List QueryDialogJump(Query query); + + Task> IAsyncDialogJump.QueryDialogJumpAsync(Query query, CancellationToken token) => Task.Run(() => QueryDialogJump(query), token); + } +} diff --git a/Flow.Launcher.Plugin/Interfaces/IDialogJumpDialog.cs b/Flow.Launcher.Plugin/Interfaces/IDialogJumpDialog.cs new file mode 100644 index 00000000000..33ad9ae73da --- /dev/null +++ b/Flow.Launcher.Plugin/Interfaces/IDialogJumpDialog.cs @@ -0,0 +1,96 @@ +using System; + +#nullable enable + +namespace Flow.Launcher.Plugin +{ + /// + /// Interface for handling file dialog instances in DialogJump. + /// + public interface IDialogJumpDialog : IFeatures, IDisposable + { + /// + /// Check if the foreground window is a file dialog instance. + /// + /// + /// The handle of the foreground window to check. + /// + /// + /// The window if the foreground window is a file dialog instance. Null if it is not. + /// + IDialogJumpDialogWindow? CheckDialogWindow(IntPtr hwnd); + } + + /// + /// Interface for handling a specific file dialog window in DialogJump. + /// + public interface IDialogJumpDialogWindow : IDisposable + { + /// + /// The handle of the dialog window. + /// + IntPtr Handle { get; } + + /// + /// Get the current tab of the dialog window. + /// + /// + IDialogJumpDialogWindowTab GetCurrentTab(); + } + + /// + /// Interface for handling a specific tab in a file dialog window in DialogJump. + /// + public interface IDialogJumpDialogWindowTab : IDisposable + { + /// + /// The handle of the dialog tab. + /// + IntPtr Handle { get; } + + /// + /// Get the current folder path of the dialog tab. + /// + /// + string GetCurrentFolder(); + + /// + /// Get the current file of the dialog tab. + /// + /// + string GetCurrentFile(); + + /// + /// Jump to a folder in the dialog tab. + /// + /// + /// The path to the folder to jump to. + /// + /// + /// Whether folder jump is under automatical mode. + /// + /// + /// True if the jump was successful, false otherwise. + /// + bool JumpFolder(string path, bool auto); + + /// + /// Jump to a file in the dialog tab. + /// + /// + /// The path to the file to jump to. + /// + /// + /// True if the jump was successful, false otherwise. + /// + bool JumpFile(string path); + + /// + /// Open the file in the dialog tab. + /// + /// + /// True if the file was opened successfully, false otherwise. + /// + bool Open(); + } +} diff --git a/Flow.Launcher.Plugin/Interfaces/IDialogJumpExplorer.cs b/Flow.Launcher.Plugin/Interfaces/IDialogJumpExplorer.cs new file mode 100644 index 00000000000..9a2b879d058 --- /dev/null +++ b/Flow.Launcher.Plugin/Interfaces/IDialogJumpExplorer.cs @@ -0,0 +1,40 @@ +using System; + +#nullable enable + +namespace Flow.Launcher.Plugin +{ + /// + /// Interface for handling file explorer instances in DialogJump. + /// + public interface IDialogJumpExplorer : IFeatures, IDisposable + { + /// + /// Check if the foreground window is a Windows Explorer instance. + /// + /// + /// The handle of the foreground window to check. + /// + /// + /// The window if the foreground window is a file explorer instance. Null if it is not. + /// + IDialogJumpExplorerWindow? CheckExplorerWindow(IntPtr hwnd); + } + + /// + /// Interface for handling a specific file explorer window in DialogJump. + /// + public interface IDialogJumpExplorerWindow : IDisposable + { + /// + /// The handle of the explorer window. + /// + IntPtr Handle { get; } + + /// + /// Get the current folder path of the explorer window. + /// + /// + string? GetExplorerPath(); + } +} diff --git a/Flow.Launcher.Plugin/Interfaces/IPlugin.cs b/Flow.Launcher.Plugin/Interfaces/IPlugin.cs index bac93d090cd..cf5a8a5829c 100644 --- a/Flow.Launcher.Plugin/Interfaces/IPlugin.cs +++ b/Flow.Launcher.Plugin/Interfaces/IPlugin.cs @@ -32,6 +32,6 @@ public interface IPlugin : IAsyncPlugin Task IAsyncPlugin.InitAsync(PluginInitContext context) => Task.Run(() => Init(context)); - Task> IAsyncPlugin.QueryAsync(Query query, CancellationToken token) => Task.Run(() => Query(query)); + Task> IAsyncPlugin.QueryAsync(Query query, CancellationToken token) => Task.Run(() => Query(query), token); } } 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 1bef1166eb2..4df8cfa0177 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.DialogJump; using Flow.Launcher.Infrastructure.Storage; using Flow.Launcher.Infrastructure.UserSettings; using Flow.Launcher.Plugin; @@ -233,6 +234,9 @@ await API.StopwatchLogInfoAsync(ClassName, "Startup cost", async () => // Initialize theme for main window Ioc.Default.GetRequiredService().ChangeTheme(); + DialogJump.InitializeDialogJump(PluginManager.GetDialogJumpExplorers(), PluginManager.GetDialogJumpDialogs()); + DialogJump.SetupDialogJump(_settings.EnableDialogJump); + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); RegisterExitEvents(); @@ -412,6 +416,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(); + DialogJump.Dispose(); } API.LogInfo(ClassName, "End Flow Launcher dispose ----------------------------------------------------"); diff --git a/Flow.Launcher/Flow.Launcher.csproj b/Flow.Launcher/Flow.Launcher.csproj index f438859f20e..67939af14fe 100644 --- a/Flow.Launcher/Flow.Launcher.csproj +++ b/Flow.Launcher/Flow.Launcher.csproj @@ -90,7 +90,6 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - @@ -103,7 +102,6 @@ - all diff --git a/Flow.Launcher/Helper/HotKeyMapper.cs b/Flow.Launcher/Helper/HotKeyMapper.cs index e5fabb3a89f..86a68475e8d 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.DialogJump; 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.EnableDialogJump) + { + SetHotkey(_settings.DialogJumpHotkey, DialogJump.OnToggleHotkey); + } LoadCustomPluginHotkey(); } diff --git a/Flow.Launcher/HotkeyControl.xaml.cs b/Flow.Launcher/HotkeyControl.xaml.cs index e8961058cdf..89bfde3497a 100644 --- a/Flow.Launcher/HotkeyControl.xaml.cs +++ b/Flow.Launcher/HotkeyControl.xaml.cs @@ -1,4 +1,4 @@ -using System.Collections.ObjectModel; +using System.Collections.ObjectModel; using System.Threading.Tasks; using System.Windows; using System.Windows.Input; @@ -110,7 +110,8 @@ public enum HotkeyType SelectPrevItemHotkey, SelectPrevItemHotkey2, SelectNextItemHotkey, - SelectNextItemHotkey2 + SelectNextItemHotkey2, + DialogJumpHotkey, } // We can initialize settings in static field because it has been constructed in App constuctor @@ -142,6 +143,7 @@ public string Hotkey HotkeyType.SelectPrevItemHotkey2 => _settings.SelectPrevItemHotkey2, HotkeyType.SelectNextItemHotkey => _settings.SelectNextItemHotkey, HotkeyType.SelectNextItemHotkey2 => _settings.SelectNextItemHotkey2, + HotkeyType.DialogJumpHotkey => _settings.DialogJumpHotkey, _ => throw new System.NotImplementedException("Hotkey type not set") }; } @@ -201,6 +203,9 @@ public string Hotkey case HotkeyType.SelectNextItemHotkey2: _settings.SelectNextItemHotkey2 = value; break; + case HotkeyType.DialogJumpHotkey: + _settings.DialogJumpHotkey = 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 ac58fdc5f7f..2fca6edf0b4 100644 --- a/Flow.Launcher/Languages/en.xaml +++ b/Flow.Launcher/Languages/en.xaml @@ -367,6 +367,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 + Dialog Jump + Enter shortcut to quickly navigate the Open/Save As dialog window to the path of the current file manager. + Dialog Jump + When Open/Save As dialog window opens, quickly navigate to the current path of the file manager. + Dialog Jump Automatically + When Open/Save As dialog window is displayed, automatically navigate to the path of the current file manager. (Experimental) + Show Dialog Jump Window + Display Dialog Jump search window when the open/save dialog window is shown to quickly navigate to file/folder locations. + Dialog Jump Window Position + Select position for the Dialog Jump search window + Fixed under the Open/Save As dialog window. Displayed on open and stays until the window is closed + Default search window position. Displayed when triggered by search window hotkey + Dialog Jump Result Navigation Behaviour + Behaviour to navigate Open/Save As dialog window to the selected result path + Left click or Enter key + Right click + Dialog Jump File Navigation Behaviour + Behaviour to navigate Open/Save As dialog window when the result is a file path + 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 0c8fb4d0209..2ddce81900e 100644 --- a/Flow.Launcher/MainWindow.xaml.cs +++ b/Flow.Launcher/MainWindow.xaml.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.ComponentModel; using System.Linq; using System.Media; @@ -19,6 +19,7 @@ using Flow.Launcher.Infrastructure; using Flow.Launcher.Infrastructure.Hotkey; using Flow.Launcher.Infrastructure.Image; +using Flow.Launcher.Infrastructure.DialogJump; using Flow.Launcher.Infrastructure.UserSettings; using Flow.Launcher.Plugin; using Flow.Launcher.Plugin.SharedCommands; @@ -119,7 +120,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) @@ -168,10 +169,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 @@ -214,6 +217,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 Dialog Jump events + InitializeDialogJump(); + // View model property changed event _viewModel.PropertyChanged += (o, e) => { @@ -226,7 +232,7 @@ private void OnLoaded(object sender, RoutedEventArgs _) if (_viewModel.MainWindowVisibilityStatus) { // Play sound effect before activing the window - if (_settings.UseSound) + if (_settings.UseSound && !_viewModel.IsDialogJumpWindowUnderDialog()) { SoundPlay(); } @@ -249,7 +255,7 @@ private void OnLoaded(object sender, RoutedEventArgs _) QueryTextBox.Focus(); // Play window animation - if (_settings.UseAnimation) + if (_settings.UseAnimation && !_viewModel.IsDialogJumpWindowUnderDialog()) { WindowAnimation(); } @@ -379,6 +385,11 @@ private void OnClosed(object sender, EventArgs e) private void OnLocationChanged(object sender, EventArgs e) { + if (_viewModel.IsDialogJumpWindowUnderDialog()) + { + return; + } + if (IsLoaded) { _settings.WindowLeft = Left; @@ -388,6 +399,11 @@ private void OnLocationChanged(object sender, EventArgs e) private async void OnDeactivated(object sender, EventArgs e) { + if (_viewModel.IsDialogJumpWindowUnderDialog()) + { + return; + } + _settings.WindowLeft = Left; _settings.WindowTop = Top; @@ -577,11 +593,23 @@ private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref b switch (msg) { case Win32Helper.WM_ENTERSIZEMOVE: + // Do do handle size move event for dialog jump window + if (_viewModel.IsDialogJumpWindowUnderDialog()) + { + return IntPtr.Zero; + } + _initialWidth = (int)Width; _initialHeight = (int)Height; handled = true; break; case Win32Helper.WM_EXITSIZEMOVE: + // Do do handle size move event for Dialog Jump window + if (_viewModel.IsDialogJumpWindowUnderDialog()) + { + return IntPtr.Zero; + } + //Prevent updating the number of results when the window height is below the height of a single result item. //This situation occurs not only when the user manually resizes the window, but also when the window is released from a side snap, as the OS automatically adjusts the window height. //(Without this check, releasing from a snap can cause the window height to hit the minimum, resulting in only 2 results being shown.) @@ -792,11 +820,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(); + if (_viewModel.IsDialogJumpWindowUnderDialog()) + { + InitializeDialogJumpPosition(); + InitializeDialogJumpPosition(); + } + else + { + InitializePosition(); + InitializePosition(); + } } private async Task PositionResetAsync() @@ -1354,6 +1390,46 @@ private void QueryTextBox_TextChanged1(object sender, TextChangedEventArgs e) #endregion + #region Dialog Jump + + private void InitializeDialogJump() + { + DialogJump.ShowDialogJumpWindowAsync = _viewModel.SetupDialogJumpAsync; + DialogJump.UpdateDialogJumpWindow = InitializeDialogJumpPosition; + DialogJump.ResetDialogJumpWindow = _viewModel.ResetDialogJump; + DialogJump.HideDialogJumpWindow = _viewModel.HideDialogJump; + } + + private void InitializeDialogJumpPosition() + { + if (_viewModel.DialogWindowHandle == nint.Zero || !_viewModel.MainWindowVisibilityStatus) return; + if (!_viewModel.IsDialogJumpWindowUnderDialog()) 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 e5b70cd87a3..2ef44f89006 100644 --- a/Flow.Launcher/SettingPages/ViewModels/SettingsPaneGeneralViewModel.cs +++ b/Flow.Launcher/SettingPages/ViewModels/SettingsPaneGeneralViewModel.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Windows.Forms; @@ -8,6 +8,7 @@ using Flow.Launcher.Core.Resource; using Flow.Launcher.Helper; using Flow.Launcher.Infrastructure; +using Flow.Launcher.Infrastructure.DialogJump; using Flow.Launcher.Infrastructure.UserSettings; using Flow.Launcher.Plugin; using Flow.Launcher.Plugin.SharedModels; @@ -146,6 +147,40 @@ public bool PortableMode public List LastQueryModes { get; } = DropdownDataGeneric.GetValues("LastQuery"); + public bool EnableDialogJump + { + get => Settings.EnableDialogJump; + set + { + if (Settings.EnableDialogJump != value) + { + Settings.EnableDialogJump = value; + DialogJump.SetupDialogJump(value); + if (Settings.EnableDialogJump) + { + HotKeyMapper.SetHotkey(new(Settings.DialogJumpHotkey), DialogJump.OnToggleHotkey); + } + else + { + HotKeyMapper.RemoveHotkey(Settings.DialogJumpHotkey); + } + } + } + } + + public class DialogJumpWindowPositionData : DropdownDataGeneric { } + public class DialogJumpResultBehaviourData : DropdownDataGeneric { } + public class DialogJumpFileResultBehaviourData : DropdownDataGeneric { } + + public List DialogJumpWindowPositions { get; } = + DropdownDataGeneric.GetValues("DialogJumpWindowPosition"); + + public List DialogJumpResultBehaviours { get; } = + DropdownDataGeneric.GetValues("DialogJumpResultBehaviour"); + + public List DialogJumpFileResultBehaviours { get; } = + DropdownDataGeneric.GetValues("DialogJumpFileResultBehaviour"); + public int SearchDelayTimeValue { get => Settings.SearchDelayTime; @@ -179,6 +214,9 @@ private void UpdateEnumDropdownLocalizations() DropdownDataGeneric.UpdateLabels(SearchPrecisionScores); DropdownDataGeneric.UpdateLabels(LastQueryModes); DropdownDataGeneric.UpdateLabels(DoublePinyinSchemas); + DropdownDataGeneric.UpdateLabels(DialogJumpWindowPositions); + DropdownDataGeneric.UpdateLabels(DialogJumpResultBehaviours); + DropdownDataGeneric.UpdateLabels(DialogJumpFileResultBehaviours); // Since we are using Binding instead of DynamicResource, we need to manually trigger the update OnPropertyChanged(nameof(AlwaysPreviewToolTip)); } diff --git a/Flow.Launcher/SettingPages/ViewModels/SettingsPaneHotkeyViewModel.cs b/Flow.Launcher/SettingPages/ViewModels/SettingsPaneHotkeyViewModel.cs index fdc9ef53029..9e6a31dc772 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.DialogJump; 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 SetDialogJumpHotkey(HotkeyModel hotkey) + { + if (Settings.EnableDialogJump) + { + HotKeyMapper.SetHotkey(hotkey, DialogJump.OnToggleHotkey); + } + } + [RelayCommand] private void CustomHotkeyDelete() { diff --git a/Flow.Launcher/SettingPages/Views/SettingsPaneGeneral.xaml b/Flow.Launcher/SettingPages/Views/SettingsPaneGeneral.xaml index f539510b0e0..81e15df6950 100644 --- a/Flow.Launcher/SettingPages/Views/SettingsPaneGeneral.xaml +++ b/Flow.Launcher/SettingPages/Views/SettingsPaneGeneral.xaml @@ -1,4 +1,4 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + _emptyResult = new List(); + private readonly IReadOnlyList _emptyDialogJumpResult = new List(); private readonly PluginMetadata _historyMetadata = new() { @@ -215,7 +217,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() { @@ -285,8 +288,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) { @@ -394,12 +405,30 @@ public void ForwardHistory() [RelayCommand] private void LoadContextMenu() { + // For Dialog Jump and right click mode, we need to navigate to the path + if (_isDialogJump && Settings.DialogJumpResultBehaviour == DialogJumpResultBehaviours.RightClick) + { + if (SelectedResults.SelectedItem != null && DialogWindowHandle != nint.Zero) + { + var result = SelectedResults.SelectedItem.Result; + if (result is DialogJumpResult dialogJumpResult) + { + Win32Helper.SetForegroundWindow(DialogWindowHandle); + _ = Task.Run(() => DialogJump.JumpToPathAsync(DialogWindowHandle, dialogJumpResult.DialogJumpPath)); + } + } + 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 { @@ -469,12 +498,34 @@ private async Task OpenResultAsync(string index) return; } - var hideWindow = await result.ExecuteAsync(new ActionContext + // For Dialog Jump and left click mode, we need to navigate to the path + if (_isDialogJump && Settings.DialogJumpResultBehaviour == DialogJumpResultBehaviours.LeftClick) { - // not null means pressing modifier key + number, should ignore the modifier key - SpecialKeyState = index is not null ? SpecialKeyState.Default : GlobalHotkey.CheckModifiers() - }) - .ConfigureAwait(false); + Hide(); + + if (SelectedResults.SelectedItem != null && DialogWindowHandle != nint.Zero) + { + if (result is DialogJumpResult dialogJumpResult) + { + Win32Helper.SetForegroundWindow(DialogWindowHandle); + _ = Task.Run(() => DialogJump.JumpToPathAsync(DialogWindowHandle, dialogJumpResult.DialogJumpPath)); + } + } + } + // For query mode, we execute the result + else + { + 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()) { @@ -482,26 +533,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 isDialogJump, CancellationToken token = default) { var resultsCopy = new List(); - foreach (var result in results.ToList()) + + if (isDialogJump) { - if (token.IsCancellationRequested) + foreach (var result in results.ToList()) { - break; + if (token.IsCancellationRequested) break; + + var resultCopy = ((DialogJumpResult)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; } @@ -1279,25 +1337,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.IsHomeQuery; + var currentIsDialogJump = _isDialogJump; + + // Do not show home page for Dialog Jump window + if (currentIsHomeQuery && currentIsDialogJump) + { + ClearResults(); + return; + } _updateSource?.Dispose(); @@ -1331,7 +1385,7 @@ private async Task QueryResultsAsync(bool searchDelay, bool isReQuery = false, b } else { - plugins = PluginManager.ValidPluginsForQuery(query); + plugins = PluginManager.ValidPluginsForQuery(query, currentIsDialogJump); if (plugins.Count == 1) { @@ -1425,6 +1479,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}>"); @@ -1442,21 +1513,23 @@ 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 = currentIsDialogJump ? + await PluginManager.QueryDialogJumpForPluginAsync(plugin, query, token) : + currentIsHomeQuery ? + await PluginManager.QueryHomeForPluginAsync(plugin, query, token) : + await PluginManager.QueryForPluginAsync(plugin, query, token); if (token.IsCancellationRequested) return; IReadOnlyList resultsCopy; if (results == null) { - resultsCopy = _emptyResult; + resultsCopy = currentIsDialogJump ? _emptyDialogJumpResult : _emptyResult; } 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, currentIsDialogJump, token); } foreach (var result in resultsCopy) @@ -1751,6 +1824,208 @@ public bool ShouldIgnoreHotkeys() #endregion + #region Dialog Jump + + public nint DialogWindowHandle { get; private set; } = nint.Zero; + + private bool _isDialogJump = false; + + private bool _previousMainWindowVisibilityStatus; + + private CancellationTokenSource _dialogJumpSource; + + public void InitializeVisibilityStatus(bool visibilityStatus) + { + _previousMainWindowVisibilityStatus = visibilityStatus; + } + + public bool IsDialogJumpWindowUnderDialog() + { + return _isDialogJump && DialogJump.DialogJumpWindowPosition == DialogJumpWindowPositions.UnderDialog; + } + + public async Task SetupDialogJumpAsync(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; + _isDialogJump = 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, clear Dialog Jump state + if (DialogWindowHandle == nint.Zero) + { + _isDialogJump = false; + return; + } + + // Initialize Dialog Jump window + if (MainWindowVisibilityStatus) + { + if (dialogWindowHandleChanged) + { + // Only update the position + Application.Current?.Dispatcher.Invoke(() => + { + (Application.Current?.MainWindow as MainWindow)?.UpdatePosition(); + }); + + _ = ResetWindowAsync(); + } + } + else + { + if (DialogJump.DialogJumpWindowPosition == DialogJumpWindowPositions.UnderDialog) + { + // We wait for window to be reset before showing it because if window has results, + // showing it before resetting will cause flickering when results are clearing + if (dialogWindowHandleChanged) + { + await ResetWindowAsync(); + } + + Show(); + } + else + { + if (dialogWindowHandleChanged) + { + _ = ResetWindowAsync(); + } + } + } + + if (DialogJump.DialogJumpWindowPosition == DialogJumpWindowPositions.UnderDialog) + { + // Cancel the previous Dialog Jump task + _dialogJumpSource?.Cancel(); + + // Create a new cancellation token source + _dialogJumpSource = new CancellationTokenSource(); + + _ = Task.Run(() => + { + try + { + // Check task cancellation + if (_dialogJumpSource.Token.IsCancellationRequested) return; + + // Check dialog handle + if (DialogWindowHandle == nint.Zero) return; + + // Wait 150ms to check if Dialog Jump window gets the focus + var timeOut = !SpinWait.SpinUntil(() => !Win32Helper.IsForegroundWindow(DialogWindowHandle), 150); + if (timeOut) return; + + // Bring focus back to 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 ResetDialogJump() + { + // Cache original dialog window handle + var dialogWindowHandle = DialogWindowHandle; + + // Reset the Dialog Jump state + DialogWindowHandle = nint.Zero; + _isDialogJump = false; + + // If dialog window handle is not set, we should not reset the main window visibility + if (dialogWindowHandle == nint.Zero) return; + + if (_previousMainWindowVisibilityStatus != MainWindowVisibilityStatus) + { + // We wait for window to be reset before showing it because if window has results, + // showing it before resetting will cause flickering when results are clearing + await ResetWindowAsync(); + + // Show or hide to change visibility + if (_previousMainWindowVisibilityStatus) + { + Show(); + } + else + { + 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 HideDialogJump() + { + if (DialogWindowHandle != nint.Zero) + { + if (DialogJump.DialogJumpWindowPosition == DialogJumpWindowPositions.UnderDialog) + { + // Warning: Main window is already in foreground + // This is because if you click popup menus in other applications to hide Dialog Jump 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 @@ -1770,7 +2045,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 && !_isDialogJump) ? 0.0 : 1.0; ClockPanelOpacity = opacity; SearchIconOpacity = opacity; @@ -1799,37 +2074,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 @@ -1839,7 +2117,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 && !_isDialogJump) ? 0.0 : 1.0; ClockPanelOpacity = opacity; SearchIconOpacity = opacity; @@ -1984,6 +2262,7 @@ protected virtual void Dispose(bool disposing) if (disposing) { _updateSource?.Dispose(); + _dialogJumpSource?.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 f1aea98b4bf..fbaefa9d66e 100644 --- a/Plugins/Flow.Launcher.Plugin.Explorer/Main.cs +++ b/Plugins/Flow.Launcher.Plugin.Explorer/Main.cs @@ -1,4 +1,4 @@ -using Flow.Launcher.Plugin.Explorer.Helper; +using Flow.Launcher.Plugin.Explorer.Helper; using Flow.Launcher.Plugin.Explorer.Search; using Flow.Launcher.Plugin.Explorer.Search.Everything; using Flow.Launcher.Plugin.Explorer.ViewModels; @@ -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, IAsyncDialogJump { internal static PluginInitContext Context { get; set; } @@ -25,6 +26,8 @@ public class Main : ISettingProvider, IAsyncPlugin, IContextMenu, IPluginI18n private SearchManager searchManager; + private static readonly List _emptyDialogJumpResultList = new(); + public Control CreateSettingPanel() { return new ExplorerSettings(viewModel); @@ -108,5 +111,18 @@ private static void FillQuickAccessLinkNames() } } } + + public async Task> QueryDialogJumpAsync(Query query, CancellationToken token) + { + try + { + var results = await searchManager.SearchAsync(query, token); + return results.Select(r => DialogJumpResult.From(r, r.CopyText)).ToList(); + } + catch (Exception e) when (e is SearchException or EngineNotAvailableException) + { + return _emptyDialogJumpResultList; + } + } } } diff --git a/Plugins/Flow.Launcher.Plugin.Explorer/Search/ResultManager.cs b/Plugins/Flow.Launcher.Plugin.Explorer/Search/ResultManager.cs index 7791a98817c..cbf6f1f8b78 100644 --- a/Plugins/Flow.Launcher.Plugin.Explorer/Search/ResultManager.cs +++ b/Plugins/Flow.Launcher.Plugin.Explorer/Search/ResultManager.cs @@ -283,15 +283,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 title = Path.GetFileName(filePath); + var isMedia = IsMedia(Path.GetExtension(filePath)); + var title = Path.GetFileName(filePath) ?? string.Empty; + var directory = Path.GetDirectoryName(filePath) ?? string.Empty; /* Preview Detail */ var result = new Result { Title = title, - SubTitle = Path.GetDirectoryName(filePath), + SubTitle = directory, IcoPath = filePath, Preview = new Result.PreviewInfo { @@ -315,7 +316,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) { @@ -323,7 +324,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)