diff --git a/src/common/ManagedCommon/Logger.cs b/src/common/ManagedCommon/Logger.cs index 150d6ea355..11115b1846 100644 --- a/src/common/ManagedCommon/Logger.cs +++ b/src/common/ManagedCommon/Logger.cs @@ -19,7 +19,9 @@ namespace ManagedCommon private static readonly string Error = "Error"; private static readonly string Warning = "Warning"; private static readonly string Info = "Info"; +#if DEBUG private static readonly string Debug = "Debug"; +#endif private static readonly string TraceFlag = "Trace"; private static readonly string Version = Assembly.GetExecutingAssembly().GetCustomAttribute()?.Version ?? "Unknown"; @@ -151,7 +153,9 @@ namespace ManagedCommon public static void LogDebug(string message, [System.Runtime.CompilerServices.CallerMemberName] string memberName = "", [System.Runtime.CompilerServices.CallerFilePath] string sourceFilePath = "", [System.Runtime.CompilerServices.CallerLineNumber] int sourceLineNumber = 0) { +#if DEBUG Log(message, Debug, memberName, sourceFilePath, sourceLineNumber); +#endif } public static void LogTrace([System.Runtime.CompilerServices.CallerMemberName] string memberName = "", [System.Runtime.CompilerServices.CallerFilePath] string sourceFilePath = "", [System.Runtime.CompilerServices.CallerLineNumber] int sourceLineNumber = 0) diff --git a/src/modules/peek/Peek.UI/Helpers/FileExplorerHelper.cs b/src/modules/peek/Peek.UI/Helpers/FileExplorerHelper.cs index fb57e80047..89e814b6f4 100644 --- a/src/modules/peek/Peek.UI/Helpers/FileExplorerHelper.cs +++ b/src/modules/peek/Peek.UI/Helpers/FileExplorerHelper.cs @@ -115,19 +115,55 @@ namespace Peek.UI.Helpers } /// - /// Returns whether the caret is visible in the specified window. + /// Heuristic to decide whether the user is actively typing so we should suppress Peek activation. + /// Current logic: + /// - If the focused control class name contains "Edit" or "Input" (e.g. Explorer search box or in-place rename), return true. + /// - Otherwise fall back to the legacy GUI_CARETBLINKING flag (covers other text contexts where class name differs but caret blinks). + /// - If we fail to retrieve GUI thread info, we default to false (do not suppress) to avoid blocking activation due to transient failures. + /// NOTE: This intentionally no longer walks ancestor chains; any Edit/Input focus inside the same top-level Explorer/Desktop window is treated as typing. /// - private static bool CaretVisible(HWND hwnd) + private static unsafe bool CaretVisible(HWND hwnd) { - GUITHREADINFO guiThreadInfo = new() { cbSize = (uint)Marshal.SizeOf() }; - - // Get information for the foreground thread - if (PInvoke_PeekUI.GetGUIThreadInfo(0, ref guiThreadInfo)) + GUITHREADINFO gi = new() { cbSize = (uint)Marshal.SizeOf() }; + if (!PInvoke_PeekUI.GetGUIThreadInfo(0, ref gi)) { - return guiThreadInfo.hwndActive == hwnd && (guiThreadInfo.flags & GUITHREADINFO_FLAGS.GUI_CARETBLINKING) != 0; + return false; // fail open (allow activation) } - return false; + // Quick sanity: restrict to same top-level window (match prior behavior) + if (gi.hwndActive != hwnd) + { + return false; + } + + HWND focus = gi.hwndFocus; + if (focus == HWND.Null) + { + return false; + } + + // Get focused window class (96 chars buffer; GetClassNameW bounds writes). Treat any class containing + // "Edit" or "Input" as a text field (search / titlebar) and suppress Peek. + Span buf = stackalloc char[96]; + fixed (char* p = buf) + { + int len = PInvoke_PeekUI.GetClassName(focus, p, buf.Length); + if (len > 0) + { + var focusClass = new string(p, 0, len); + if (focusClass.Contains("Edit", StringComparison.OrdinalIgnoreCase) || focusClass.Contains("Input", StringComparison.OrdinalIgnoreCase)) + { + return true; // treat any Edit/Input focus as typing. + } + else + { + ManagedCommon.Logger.LogDebug($"Peek suppression: focus class{focusClass}"); + } + } + } + + // Fallback: original caret blinking heuristic for other text-entry contexts + return (gi.flags & GUITHREADINFO_FLAGS.GUI_CARETBLINKING) != 0; } } } diff --git a/src/modules/peek/peek/dllmain.cpp b/src/modules/peek/peek/dllmain.cpp index 4c3da5d999..1127df38bd 100644 --- a/src/modules/peek/peek/dllmain.cpp +++ b/src/modules/peek/peek/dllmain.cpp @@ -1,15 +1,17 @@ #include "pch.h" -#include +#include "trace.h" +#include +#include +#include +#include #include #include -#include "trace.h" -#include -#include -#include -#include -#include -#include #include +#include +#include +#include +#include +#include extern "C" IMAGE_DOS_HEADER __ImageBase; @@ -32,6 +34,9 @@ BOOL APIENTRY DllMain(HMODULE /*hModule*/, return TRUE; } +// Forward declare global Peek so anonymous namespace uses same type +class Peek; + namespace { const wchar_t JSON_KEY_PROPERTIES[] = L"properties"; @@ -42,6 +47,17 @@ namespace const wchar_t JSON_KEY_CODE[] = L"code"; const wchar_t JSON_KEY_ACTIVATION_SHORTCUT[] = L"ActivationShortcut"; const wchar_t JSON_KEY_ALWAYS_RUN_NOT_ELEVATED[] = L"AlwaysRunNotElevated"; + const wchar_t JSON_KEY_ENABLE_SPACE_TO_ACTIVATE[] = L"EnableSpaceToActivate"; + + // Space activation (single-space mode) state + std::atomic_bool g_foregroundHookActive{ false }; // Foreground hook installed + std::atomic_bool g_foregroundEligible{ false }; // Cached eligibility (Explorer/Desktop/Peek focused) + HWINEVENTHOOK g_foregroundHook = nullptr; // Foreground change hook handle + constexpr DWORD FOREGROUND_DEBOUNCE_MS = 40; // Delay before eligibility recompute (ms) + HANDLE g_foregroundDebounceTimer = nullptr; // One-shot scheduled timer + std::atomic g_foregroundLastScheduleTick{ 0 }; // Tick count when timer last scheduled + + Peek* g_instance = nullptr; // pointer to active instance (global Peek) } // The PowerToy name that will be shown in the settings. @@ -60,6 +76,7 @@ private: // If we should always try to run Peek non-elevated. bool m_alwaysRunNotElevated = true; + bool m_enableSpaceToActivate = false; // toggle from settings HANDLE m_hProcess = 0; DWORD m_processPid = 0; @@ -111,11 +128,55 @@ private: m_alwaysRunNotElevated = true; } + try + { + auto jsonEnableSpaceObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_ENABLE_SPACE_TO_ACTIVATE); + m_enableSpaceToActivate = jsonEnableSpaceObject.GetNamedBoolean(L"value"); + } + catch (...) + { + m_enableSpaceToActivate = false; + } + + // Enforce design: if space toggle ON, force single-space hotkey and store previous combination once. + if (m_enableSpaceToActivate) + { + if (!(m_hotkey.win || m_hotkey.alt || m_hotkey.shift || m_hotkey.ctrl) && m_hotkey.key == ' ') + { + // already single space + } + else + { + m_hotkey.win = false; + m_hotkey.alt = false; + m_hotkey.shift = false; + m_hotkey.ctrl = false; + m_hotkey.key = ' '; + } + } + else + { + // If toggle off and current hotkey is bare space, revert to default (simplified policy) + if (!(m_hotkey.win || m_hotkey.alt || m_hotkey.shift || m_hotkey.ctrl) && m_hotkey.key == ' ') + { + set_default_key_settings(); + } + } + + manage_space_mode_hook(); + Trace::SpaceModeEnabled(m_enableSpaceToActivate); } else { - Logger::info("Peek settings are empty"); - set_default_key_settings(); + // First-run (no existing settings file or empty JSON): default to Space-only activation + Logger::info("Peek settings are empty - initializing first-run defaults (Space activation)"); + m_enableSpaceToActivate = true; + m_hotkey.win = false; + m_hotkey.alt = false; + m_hotkey.shift = false; + m_hotkey.ctrl = false; + m_hotkey.key = ' '; + Trace::SpaceModeEnabled(true); } } @@ -129,6 +190,111 @@ private: m_hotkey.key = ' '; } + // Eligibility recompute (debounced via timer) +public: // callable from anonymous namespace helper + void recompute_space_mode_eligibility() + { + if (!m_enableSpaceToActivate) + { + g_foregroundEligible.store(false, std::memory_order_relaxed); + return; + } + const bool eligible = is_peek_or_explorer_or_desktop_window_focused(); + g_foregroundEligible.store(eligible, std::memory_order_relaxed); + Logger::debug(L"Peek space-mode eligibility recomputed: {}", eligible); + } + +private: + static void CALLBACK ForegroundDebounceTimerProc(PVOID /*param*/, BOOLEAN /*fired*/) + { + if (!g_instance || !g_foregroundHookActive.load(std::memory_order_relaxed)) + { + return; + } + g_instance->recompute_space_mode_eligibility(); + } + + static void CALLBACK ForegroundWinEventProc(HWINEVENTHOOK /*hook*/, DWORD /*event*/, HWND /*hwnd*/, LONG /*idObject*/, LONG /*idChild*/, DWORD /*thread*/, DWORD /*time*/) + { + if (!g_foregroundHookActive.load(std::memory_order_relaxed) || !g_instance) + { + return; + } + const DWORD now = GetTickCount(); + const DWORD last = g_foregroundLastScheduleTick.load(std::memory_order_relaxed); + // If no timer or sufficient time since last schedule, create a new one. + if (!g_foregroundDebounceTimer || (now - last) >= FOREGROUND_DEBOUNCE_MS || now < last) + { + if (g_foregroundDebounceTimer) + { + // Best effort: cancel previous pending timer; ignore failure. + DeleteTimerQueueTimer(nullptr, g_foregroundDebounceTimer, INVALID_HANDLE_VALUE); + g_foregroundDebounceTimer = nullptr; + } + if (CreateTimerQueueTimer(&g_foregroundDebounceTimer, nullptr, ForegroundDebounceTimerProc, nullptr, FOREGROUND_DEBOUNCE_MS, 0, WT_EXECUTEDEFAULT)) + { + g_foregroundLastScheduleTick.store(now, std::memory_order_relaxed); + } + else + { + Logger::warn(L"Peek failed to create foreground debounce timer"); + // Fallback: compute immediately if timer creation failed. + g_instance->recompute_space_mode_eligibility(); + } + } + } + + void install_foreground_hook() + { + if (g_foregroundHook || !m_enableSpaceToActivate) + { + return; + } + + g_instance = this; + g_foregroundHook = SetWinEventHook(EVENT_SYSTEM_FOREGROUND, EVENT_SYSTEM_FOREGROUND, nullptr, ForegroundWinEventProc, 0, 0, WINEVENT_OUTOFCONTEXT | WINEVENT_SKIPOWNPROCESS); + if (g_foregroundHook) + { + g_foregroundHookActive.store(true, std::memory_order_relaxed); + recompute_space_mode_eligibility(); + } + else + { + g_foregroundHookActive.store(false, std::memory_order_relaxed); + Logger::warn(L"Peek failed to install foreground hook. Falling back to polling."); + } + } + + void uninstall_foreground_hook() + { + if (g_foregroundHook) + { + UnhookWinEvent(g_foregroundHook); + g_foregroundHook = nullptr; + } + if (g_foregroundDebounceTimer) + { + DeleteTimerQueueTimer(nullptr, g_foregroundDebounceTimer, INVALID_HANDLE_VALUE); + g_foregroundDebounceTimer = nullptr; + } + g_foregroundLastScheduleTick.store(0, std::memory_order_relaxed); + g_foregroundHookActive.store(false, std::memory_order_relaxed); + g_foregroundEligible.store(false, std::memory_order_relaxed); + g_instance = nullptr; + } + + void manage_space_mode_hook() + { + if (m_enableSpaceToActivate && m_enabled) + { + install_foreground_hook(); + } + else + { + uninstall_foreground_hook(); + } + } + void parse_hotkey(winrt::Windows::Data::Json::JsonObject& jsonHotkeyObject) { try @@ -319,6 +485,7 @@ private: public: Peek() { + LoggerHelpers::init_logger(MODULE_NAME, L"ModuleInterface", "Peek"); init_settings(); m_hInvokeEvent = CreateDefaultEvent(CommonSharedConstants::SHOW_PEEK_SHARED_EVENT); @@ -331,6 +498,7 @@ public: { } m_enabled = false; + uninstall_foreground_hook(); }; // Destroy the powertoy and free memory @@ -364,6 +532,7 @@ public: // Create a Settings object. PowerToysSettings::Settings settings(hinstance, get_name()); settings.set_description(MODULE_DESC); + settings.add_bool_toggle(JSON_KEY_ENABLE_SPACE_TO_ACTIVATE, L"Enable single Space key activation", m_enableSpaceToActivate); return settings.serialize_to_buffer(buffer, buffer_size); } @@ -395,6 +564,7 @@ public: launch_process(); m_enabled = true; Trace::EnablePeek(true); + manage_space_mode_hook(); } // Disable the powertoy @@ -425,6 +595,7 @@ public: m_enabled = false; Trace::EnablePeek(false); + uninstall_foreground_hook(); } // Returns if the powertoys is enabled @@ -454,11 +625,21 @@ public: { if (m_enabled) { - Logger::trace(L"Peek hotkey pressed"); - - // Only activate and consume the shortcut if a Peek, explorer or desktop window is the foreground application. - if (is_peek_or_explorer_or_desktop_window_focused()) + bool spaceMode = m_enableSpaceToActivate && !(m_hotkey.win || m_hotkey.alt || m_hotkey.shift || m_hotkey.ctrl) && m_hotkey.key == ' '; + bool eligible = false; + if (spaceMode && g_foregroundHookActive.load(std::memory_order_relaxed)) { + eligible = g_foregroundEligible.load(std::memory_order_relaxed); + } + else + { + eligible = is_peek_or_explorer_or_desktop_window_focused(); + } + + if (eligible) + { + Logger::trace(L"Peek hotkey pressed and eligible for launching"); + // TODO: fix VK_SPACE DestroyWindow in viewer app if (!is_viewer_running()) { @@ -468,7 +649,16 @@ public: SetEvent(m_hInvokeEvent); Trace::PeekInvoked(); - return true; + + + if (spaceMode) + { + return false; + } + else + { + return true; + } } } diff --git a/src/modules/peek/peek/trace.cpp b/src/modules/peek/peek/trace.cpp index 529abb94f3..a1dd6355a2 100644 --- a/src/modules/peek/peek/trace.cpp +++ b/src/modules/peek/peek/trace.cpp @@ -48,3 +48,13 @@ void Trace::SettingsTelemetry(PowertoyModuleIface::Hotkey& hotkey) noexcept TraceLoggingKeyword(PROJECT_KEYWORD_MEASURE), TraceLoggingWideString(hotKeyStr.c_str(), "HotKey")); } + +void Trace::SpaceModeEnabled(bool enabled) noexcept +{ + TraceLoggingWriteWrapper( + g_hProvider, + "Peek_SpaceModeEnabled", + ProjectTelemetryPrivacyDataTag(ProjectTelemetryTag_ProductAndServicePerformance), + TraceLoggingKeyword(PROJECT_KEYWORD_MEASURE), + TraceLoggingBoolean(enabled, "Enabled")); +} diff --git a/src/modules/peek/peek/trace.h b/src/modules/peek/peek/trace.h index c250fc6b45..b5c22e7645 100644 --- a/src/modules/peek/peek/trace.h +++ b/src/modules/peek/peek/trace.h @@ -15,4 +15,7 @@ public: // Event to send settings telemetry. static void Trace::SettingsTelemetry(PowertoyModuleIface::Hotkey& hotkey) noexcept; + // Space mode telemetry (single-key activation toggle) + static void SpaceModeEnabled(bool enabled) noexcept; + }; diff --git a/src/settings-ui/Settings.UI.Library/PeekProperties.cs b/src/settings-ui/Settings.UI.Library/PeekProperties.cs index f81a3bc9a6..e6eea746d6 100644 --- a/src/settings-ui/Settings.UI.Library/PeekProperties.cs +++ b/src/settings-ui/Settings.UI.Library/PeekProperties.cs @@ -19,6 +19,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library AlwaysRunNotElevated = new BoolProperty(true); CloseAfterLosingFocus = new BoolProperty(false); ConfirmFileDelete = new BoolProperty(true); + EnableSpaceToActivate = new BoolProperty(true); // Toggle is ON by default for new users. No impact on existing users. } public HotkeySettings ActivationShortcut { get; set; } @@ -29,6 +30,8 @@ namespace Microsoft.PowerToys.Settings.UI.Library public BoolProperty ConfirmFileDelete { get; set; } + public BoolProperty EnableSpaceToActivate { get; set; } + public override string ToString() => JsonSerializer.Serialize(this); } } diff --git a/src/settings-ui/Settings.UI.Library/PeekSettings.cs b/src/settings-ui/Settings.UI.Library/PeekSettings.cs index 73993c72fa..8bc4f6ee76 100644 --- a/src/settings-ui/Settings.UI.Library/PeekSettings.cs +++ b/src/settings-ui/Settings.UI.Library/PeekSettings.cs @@ -15,7 +15,9 @@ namespace Microsoft.PowerToys.Settings.UI.Library public class PeekSettings : BasePTModuleSettings, ISettingsConfig, IHotkeyConfig { public const string ModuleName = "Peek"; - public const string ModuleVersion = "0.0.1"; + public const string InitialModuleVersion = "0.0.1"; + public const string SpaceActivationIntroducedVersion = "0.0.2"; + public const string CurrentModuleVersion = SpaceActivationIntroducedVersion; private static readonly JsonSerializerOptions _serializerOptions = new JsonSerializerOptions { @@ -28,7 +30,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library public PeekSettings() { Name = ModuleName; - Version = ModuleVersion; + Version = CurrentModuleVersion; Properties = new PeekProperties(); } @@ -54,6 +56,14 @@ namespace Microsoft.PowerToys.Settings.UI.Library public bool UpgradeSettingsConfiguration() { + if (string.IsNullOrEmpty(Version) || + Version.Equals(InitialModuleVersion, StringComparison.OrdinalIgnoreCase)) + { + Version = CurrentModuleVersion; + Properties.EnableSpaceToActivate.Value = false; + return true; + } + return false; } diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/PeekPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/PeekPage.xaml index 5ad3a998ad..49da343744 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/PeekPage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/PeekPage.xaml @@ -22,11 +22,19 @@ + + + + + + + + HeaderIcon="{ui:FontIcon Glyph=}" + Visibility="{x:Bind ViewModel.EnableSpaceToActivate, Mode=OneWay, Converter={StaticResource ReverseBoolToVisibilityConverter}}"> diff --git a/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw b/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw index d02ecafc21..9a3009ca80 100644 --- a/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw +++ b/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw @@ -3152,6 +3152,19 @@ Right-click to remove the key combination, thereby deactivating the shortcut. You'll be asked to confirm before files are moved to the Recycle Bin + + Activation method + + + Use a shortcut or press the Spacebar when a file is selected + Spacebar is a physical keyboard key + + + Custom shortcut + + + Spacebar + Disable rounded corners when a window is snapped diff --git a/src/settings-ui/Settings.UI/ViewModels/PeekViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/PeekViewModel.cs index 3688e2e14d..85ffbda2d9 100644 --- a/src/settings-ui/Settings.UI/ViewModels/PeekViewModel.cs +++ b/src/settings-ui/Settings.UI/ViewModels/PeekViewModel.cs @@ -170,6 +170,12 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels { if (_peekSettings.Properties.ActivationShortcut != value) { + // If space mode toggle is on, ignore external attempts to change (UI will be disabled, but defensive). + if (EnableSpaceToActivate) + { + return; + } + _peekSettings.Properties.ActivationShortcut = value ?? _peekSettings.Properties.DefaultActivationShortcut; OnPropertyChanged(nameof(ActivationShortcut)); NotifySettingsChanged(); @@ -219,6 +225,33 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels } } + public bool EnableSpaceToActivate + { + get => _peekSettings.Properties.EnableSpaceToActivate.Value; + set + { + if (_peekSettings.Properties.EnableSpaceToActivate.Value != value) + { + _peekSettings.Properties.EnableSpaceToActivate.Value = value; + + if (value) + { + // Force single space (0x20) without modifiers. + _peekSettings.Properties.ActivationShortcut = new HotkeySettings(false, false, false, false, 0x20); + } + else + { + // Revert to default (design simplification, not restoring previous custom combo). + _peekSettings.Properties.ActivationShortcut = _peekSettings.Properties.DefaultActivationShortcut; + } + + OnPropertyChanged(nameof(EnableSpaceToActivate)); + OnPropertyChanged(nameof(ActivationShortcut)); + NotifySettingsChanged(); + } + } + } + public bool SourceCodeWrapText { get => _peekPreviewSettings.SourceCodeWrapText.Value;