From f822826cf136916d099d2335b95b7bc9be38f834 Mon Sep 17 00:00:00 2001 From: Jaylyn Barbee <51131738+Jaylyn-Barbee@users.noreply.github.com> Date: Wed, 10 Dec 2025 08:15:34 -0500 Subject: [PATCH 1/8] [Light Switch] Follow Night Light mode (#43683) ## Summary of the Pull Request Introduces a new mode that will have Light Switch follow Windows Night Light. ## PR Checklist - [x] Closes: https://github.com/microsoft/PowerToys/issues/42457 - [x] **Communication:** I've discussed this with core contributors already. If the work hasn't been agreed, this work might be rejected - [ ] **Tests:** Added/updated and all pass - [x] **Localization:** All end-user-facing strings can be localized - [ ] **Documentation updated:** If checked, please file a pull request on [our docs repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys) and link it here: #xxx ## Detailed Description of the Pull Request / Additional comments Strictly follows the state of Night Light. When NL is on, LS will be switch to dark mode and when NL is off, LS will switch to light mode (with respect to the users system/app selection). ## Validation Steps Performed Turn on Follow Night Light mode Change night light! ## Notes --- .github/actions/spell-check/expect.txt | 3 + .../LightSwitchModuleInterface/dllmain.cpp | 9 +- .../LightSwitchService/LightSwitchService.cpp | 67 ++++++++- .../LightSwitchService.vcxproj | 2 + .../LightSwitchService.vcxproj.filters | 6 + .../LightSwitchService/LightSwitchSettings.h | 7 +- .../LightSwitchStateManager.cpp | 56 ++++++-- .../LightSwitchStateManager.h | 4 + .../NightLightRegistryObserver.cpp | 1 + .../NightLightRegistryObserver.h | 134 ++++++++++++++++++ .../LightSwitchService/SettingsConstants.h | 5 +- .../LightSwitchService/ThemeHelper.cpp | 38 ++++- .../LightSwitchService/ThemeHelper.h | 1 + .../Settings.UI/Helpers/StartProcessHelper.cs | 1 + .../SettingsXAML/Views/LightSwitchPage.xaml | 43 ++++++ .../Views/LightSwitchPage.xaml.cs | 16 +++ .../Settings.UI/Strings/en-us/Resources.resw | 65 +++++---- .../ViewModels/LightSwitchViewModel.cs | 1 + 18 files changed, 413 insertions(+), 46 deletions(-) create mode 100644 src/modules/LightSwitch/LightSwitchService/NightLightRegistryObserver.cpp create mode 100644 src/modules/LightSwitch/LightSwitchService/NightLightRegistryObserver.h diff --git a/.github/actions/spell-check/expect.txt b/.github/actions/spell-check/expect.txt index 4a3305217e..f839c5976c 100644 --- a/.github/actions/spell-check/expect.txt +++ b/.github/actions/spell-check/expect.txt @@ -144,6 +144,8 @@ BLENDFUNCTION blittable Blockquotes blt +bluelightreduction +bluelightreductionstate BLURBEHIND BLURREGION bmi @@ -1115,6 +1117,7 @@ NEWPLUSSHELLEXTENSIONWIN newrow nicksnettravels NIF +nightlight NLog NLSTEXT NMAKE diff --git a/src/modules/LightSwitch/LightSwitchModuleInterface/dllmain.cpp b/src/modules/LightSwitch/LightSwitchModuleInterface/dllmain.cpp index 170dde5b0a..a5973a396f 100644 --- a/src/modules/LightSwitch/LightSwitchModuleInterface/dllmain.cpp +++ b/src/modules/LightSwitch/LightSwitchModuleInterface/dllmain.cpp @@ -50,6 +50,7 @@ enum class ScheduleMode Off, FixedHours, SunsetToSunrise, + FollowNightLight, // add more later }; @@ -61,6 +62,8 @@ inline std::wstring ToString(ScheduleMode mode) return L"SunsetToSunrise"; case ScheduleMode::FixedHours: return L"FixedHours"; + case ScheduleMode::FollowNightLight: + return L"FollowNightLight"; default: return L"Off"; } @@ -72,6 +75,8 @@ inline ScheduleMode FromString(const std::wstring& str) return ScheduleMode::SunsetToSunrise; if (str == L"FixedHours") return ScheduleMode::FixedHours; + if (str == L"FollowNightLight") + return ScheduleMode::FollowNightLight; return ScheduleMode::Off; } @@ -167,7 +172,9 @@ public: ToString(g_settings.m_scheduleMode), { { L"Off", L"Disable the schedule" }, { L"FixedHours", L"Set hours manually" }, - { L"SunsetToSunrise", L"Use sunrise/sunset times" } }); + { L"SunsetToSunrise", L"Use sunrise/sunset times" }, + { L"FollowNightLight", L"Follow Windows Night Light state" } + }); // Integer spinners settings.add_int_spinner( diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchService.cpp b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.cpp index 845e24fa93..b6684da54e 100644 --- a/src/modules/LightSwitch/LightSwitchService/LightSwitchService.cpp +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.cpp @@ -13,10 +13,12 @@ #include #include "LightSwitchStateManager.h" #include +#include SERVICE_STATUS g_ServiceStatus = {}; SERVICE_STATUS_HANDLE g_StatusHandle = nullptr; HANDLE g_ServiceStopEvent = nullptr; +static LightSwitchStateManager* g_stateManagerPtr = nullptr; VOID WINAPI ServiceMain(DWORD argc, LPTSTR* argv); VOID WINAPI ServiceCtrlHandler(DWORD dwCtrl); @@ -168,7 +170,15 @@ static void DetectAndHandleExternalThemeChange(LightSwitchStateManager& stateMan } // Use shared helper (handles wraparound logic) - bool shouldBeLight = ShouldBeLight(nowMinutes, effectiveLight, effectiveDark); + bool shouldBeLight = false; + if (s.scheduleMode == ScheduleMode::FollowNightLight) + { + shouldBeLight = !IsNightLightEnabled(); + } + else + { + shouldBeLight = ShouldBeLight(nowMinutes, effectiveLight, effectiveDark); + } // Compare current system/apps theme bool currentSystemLight = GetCurrentSystemTheme(); @@ -199,15 +209,40 @@ DWORD WINAPI ServiceWorkerThread(LPVOID lpParam) // Initialization // ──────────────────────────────────────────────────────────────── static LightSwitchStateManager stateManager; + g_stateManagerPtr = &stateManager; LightSwitchSettings::instance().InitFileWatcher(); HANDLE hManualOverride = OpenEventW(SYNCHRONIZE | EVENT_MODIFY_STATE, FALSE, L"POWERTOYS_LIGHTSWITCH_MANUAL_OVERRIDE"); HANDLE hSettingsChanged = LightSwitchSettings::instance().GetSettingsChangedEvent(); + static std::unique_ptr g_nightLightWatcher; + LightSwitchSettings::instance().LoadSettings(); const auto& settings = LightSwitchSettings::instance().settings(); + // after loading settings: + bool nightLightNeeded = (settings.scheduleMode == ScheduleMode::FollowNightLight); + + if (nightLightNeeded && !g_nightLightWatcher) + { + Logger::info(L"[LightSwitchService] Starting Night Light registry watcher..."); + + g_nightLightWatcher = std::make_unique( + HKEY_CURRENT_USER, + NIGHT_LIGHT_REGISTRY_PATH, + []() { + if (g_stateManagerPtr) + g_stateManagerPtr->OnNightLightChange(); + }); + } + else if (!nightLightNeeded && g_nightLightWatcher) + { + Logger::info(L"[LightSwitchService] Stopping Night Light registry watcher..."); + g_nightLightWatcher->Stop(); + g_nightLightWatcher.reset(); + } + SYSTEMTIME st; GetLocalTime(&st); int nowMinutes = st.wHour * 60 + st.wMinute; @@ -274,6 +309,31 @@ DWORD WINAPI ServiceWorkerThread(LPVOID lpParam) ResetEvent(hSettingsChanged); LightSwitchSettings::instance().LoadSettings(); stateManager.OnSettingsChanged(); + + const auto& settings = LightSwitchSettings::instance().settings(); + bool nightLightNeeded = (settings.scheduleMode == ScheduleMode::FollowNightLight); + + if (nightLightNeeded && !g_nightLightWatcher) + { + Logger::info(L"[LightSwitchService] Starting Night Light registry watcher..."); + + g_nightLightWatcher = std::make_unique( + HKEY_CURRENT_USER, + NIGHT_LIGHT_REGISTRY_PATH, + []() { + if (g_stateManagerPtr) + g_stateManagerPtr->OnNightLightChange(); + }); + + stateManager.OnNightLightChange(); + } + else if (!nightLightNeeded && g_nightLightWatcher) + { + Logger::info(L"[LightSwitchService] Stopping Night Light registry watcher..."); + g_nightLightWatcher->Stop(); + g_nightLightWatcher.reset(); + } + continue; } } @@ -285,6 +345,11 @@ DWORD WINAPI ServiceWorkerThread(LPVOID lpParam) CloseHandle(hManualOverride); if (hParent) CloseHandle(hParent); + if (g_nightLightWatcher) + { + g_nightLightWatcher->Stop(); + g_nightLightWatcher.reset(); + } Logger::info(L"[LightSwitchService] Worker thread exiting cleanly."); return 0; diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj index a3a505f897..e1c8052de6 100644 --- a/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj @@ -76,6 +76,7 @@ + @@ -88,6 +89,7 @@ + diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj.filters b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj.filters index 795df99aba..55c7bde39b 100644 --- a/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj.filters +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj.filters @@ -36,6 +36,9 @@ Source Files + + Source Files + @@ -62,6 +65,9 @@ Header Files + + Header Files + diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchSettings.h b/src/modules/LightSwitch/LightSwitchService/LightSwitchSettings.h index d4029d072d..1d1c7953fe 100644 --- a/src/modules/LightSwitch/LightSwitchService/LightSwitchSettings.h +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchSettings.h @@ -19,7 +19,8 @@ enum class ScheduleMode { Off, FixedHours, - SunsetToSunrise + SunsetToSunrise, + FollowNightLight, // Add more in the future }; @@ -31,6 +32,8 @@ inline std::wstring ToString(ScheduleMode mode) return L"FixedHours"; case ScheduleMode::SunsetToSunrise: return L"SunsetToSunrise"; + case ScheduleMode::FollowNightLight: + return L"FollowNightLight"; default: return L"Off"; } @@ -42,6 +45,8 @@ inline ScheduleMode FromString(const std::wstring& str) return ScheduleMode::SunsetToSunrise; if (str == L"FixedHours") return ScheduleMode::FixedHours; + if (str == L"FollowNightLight") + return ScheduleMode::FollowNightLight; else return ScheduleMode::Off; } diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.cpp b/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.cpp index 4fba4ae9a6..f562d38c41 100644 --- a/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.cpp +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.cpp @@ -31,7 +31,10 @@ void LightSwitchStateManager::OnSettingsChanged() void LightSwitchStateManager::OnTick(int currentMinutes) { std::lock_guard lock(_stateMutex); - EvaluateAndApplyIfNeeded(); + if (_state.lastAppliedMode != ScheduleMode::FollowNightLight) + { + EvaluateAndApplyIfNeeded(); + } } // Called when manual override is triggered @@ -49,8 +52,38 @@ void LightSwitchStateManager::OnManualOverride() _state.isAppsLightActive = GetCurrentAppsTheme(); Logger::debug(L"[LightSwitchStateManager] Synced internal theme state to current system theme ({}) and apps theme ({}).", - (_state.isSystemLightActive ? L"light" : L"dark"), - (_state.isAppsLightActive ? L"light" : L"dark")); + (_state.isSystemLightActive ? L"light" : L"dark"), + (_state.isAppsLightActive ? L"light" : L"dark")); + } + + EvaluateAndApplyIfNeeded(); +} + +// Runs with the registry observer detects a change in Night Light settings. +void LightSwitchStateManager::OnNightLightChange() +{ + std::lock_guard lock(_stateMutex); + + bool newNightLightState = IsNightLightEnabled(); + + // In Follow Night Light mode, treat a Night Light toggle as a boundary + if (_state.lastAppliedMode == ScheduleMode::FollowNightLight && _state.isManualOverride) + { + Logger::info(L"[LightSwitchStateManager] Night Light changed while manual override active; " + L"treating as a boundary and clearing manual override."); + _state.isManualOverride = false; + } + + if (newNightLightState != _state.isNightLightActive) + { + Logger::info(L"[LightSwitchStateManager] Night Light toggled to {}", + newNightLightState ? L"ON" : L"OFF"); + + _state.isNightLightActive = newNightLightState; + } + else + { + Logger::debug(L"[LightSwitchStateManager] Night Light change event fired, but no actual change."); } EvaluateAndApplyIfNeeded(); @@ -77,9 +110,9 @@ void LightSwitchStateManager::SyncInitialThemeState() _state.isSystemLightActive = GetCurrentSystemTheme(); _state.isAppsLightActive = GetCurrentAppsTheme(); Logger::debug(L"[LightSwitchStateManager] Synced initial state to current system theme ({})", - _state.isSystemLightActive ? L"light" : L"dark"); + _state.isSystemLightActive ? L"light" : L"dark"); Logger::debug(L"[LightSwitchStateManager] Synced initial state to current apps theme ({})", - _state.isAppsLightActive ? L"light" : L"dark"); + _state.isAppsLightActive ? L"light" : L"dark"); } static std::pair update_sun_times(auto& settings) @@ -194,7 +227,15 @@ void LightSwitchStateManager::EvaluateAndApplyIfNeeded() _state.lastAppliedMode = _currentSettings.scheduleMode; - bool shouldBeLight = ShouldBeLight(now, _state.effectiveLightMinutes, _state.effectiveDarkMinutes); + bool shouldBeLight = false; + if (_currentSettings.scheduleMode == ScheduleMode::FollowNightLight) + { + shouldBeLight = !_state.isNightLightActive; + } + else + { + shouldBeLight = ShouldBeLight(now, _state.effectiveLightMinutes, _state.effectiveDarkMinutes); + } bool appsNeedsToChange = _currentSettings.changeApps && (_state.isAppsLightActive != shouldBeLight); bool systemNeedsToChange = _currentSettings.changeSystem && (_state.isSystemLightActive != shouldBeLight); @@ -227,6 +268,3 @@ void LightSwitchStateManager::EvaluateAndApplyIfNeeded() _state.lastTickMinutes = now; } - - - diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.h b/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.h index 5c9bcc6e25..c4f39a2e9a 100644 --- a/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.h +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.h @@ -9,6 +9,7 @@ struct LightSwitchState bool isManualOverride = false; bool isSystemLightActive = false; bool isAppsLightActive = false; + bool isNightLightActive = false; int lastEvaluatedDay = -1; int lastTickMinutes = -1; @@ -32,6 +33,9 @@ public: // Called when manual override is toggled (via shortcut or system change). void OnManualOverride(); + // Called when night light changes in windows settings + void OnNightLightChange(); + // Initial sync at startup to align internal state with system theme void SyncInitialThemeState(); diff --git a/src/modules/LightSwitch/LightSwitchService/NightLightRegistryObserver.cpp b/src/modules/LightSwitch/LightSwitchService/NightLightRegistryObserver.cpp new file mode 100644 index 0000000000..8da19c6595 --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchService/NightLightRegistryObserver.cpp @@ -0,0 +1 @@ +#include "NightLightRegistryObserver.h" diff --git a/src/modules/LightSwitch/LightSwitchService/NightLightRegistryObserver.h b/src/modules/LightSwitch/LightSwitchService/NightLightRegistryObserver.h new file mode 100644 index 0000000000..2806c28316 --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchService/NightLightRegistryObserver.h @@ -0,0 +1,134 @@ +#pragma once +#include +#include +#include +#include +#include +#include + +class NightLightRegistryObserver +{ +public: + NightLightRegistryObserver(HKEY root, const std::wstring& subkey, std::function callback) : + _root(root), _subkey(subkey), _callback(std::move(callback)), _stop(false) + { + _thread = std::thread([this]() { this->Run(); }); + } + + ~NightLightRegistryObserver() + { + Stop(); + } + + void Stop() + { + _stop = true; + + { + std::lock_guard lock(_mutex); + if (_event) + SetEvent(_event); + } + + if (_thread.joinable()) + _thread.join(); + + std::lock_guard lock(_mutex); + if (_hKey) + { + RegCloseKey(_hKey); + _hKey = nullptr; + } + + if (_event) + { + CloseHandle(_event); + _event = nullptr; + } + } + + +private: + void Run() + { + { + std::lock_guard lock(_mutex); + if (RegOpenKeyExW(_root, _subkey.c_str(), 0, KEY_NOTIFY, &_hKey) != ERROR_SUCCESS) + return; + + _event = CreateEventW(nullptr, TRUE, FALSE, nullptr); + if (!_event) + { + RegCloseKey(_hKey); + _hKey = nullptr; + return; + } + } + + while (!_stop) + { + HKEY hKeyLocal = nullptr; + HANDLE eventLocal = nullptr; + + { + std::lock_guard lock(_mutex); + if (_stop) + break; + + hKeyLocal = _hKey; + eventLocal = _event; + } + + if (!hKeyLocal || !eventLocal) + break; + + if (_stop) + break; + + if (RegNotifyChangeKeyValue(hKeyLocal, FALSE, REG_NOTIFY_CHANGE_LAST_SET, eventLocal, TRUE) != ERROR_SUCCESS) + break; + + DWORD wait = WaitForSingleObject(eventLocal, INFINITE); + if (_stop || wait == WAIT_FAILED) + break; + + ResetEvent(eventLocal); + + if (!_stop && _callback) + { + try + { + _callback(); + } + catch (...) + { + } + } + } + + { + std::lock_guard lock(_mutex); + if (_hKey) + { + RegCloseKey(_hKey); + _hKey = nullptr; + } + + if (_event) + { + CloseHandle(_event); + _event = nullptr; + } + } + } + + + HKEY _root; + std::wstring _subkey; + std::function _callback; + HANDLE _event = nullptr; + HKEY _hKey = nullptr; + std::thread _thread; + std::atomic _stop; + std::mutex _mutex; +}; \ No newline at end of file diff --git a/src/modules/LightSwitch/LightSwitchService/SettingsConstants.h b/src/modules/LightSwitch/LightSwitchService/SettingsConstants.h index 4872864eff..8015c9b3e6 100644 --- a/src/modules/LightSwitch/LightSwitchService/SettingsConstants.h +++ b/src/modules/LightSwitch/LightSwitchService/SettingsConstants.h @@ -11,4 +11,7 @@ enum class SettingId Sunset_Offset, ChangeSystem, ChangeApps -}; \ No newline at end of file +}; + +constexpr wchar_t PERSONALIZATION_REGISTRY_PATH[] = L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr wchar_t NIGHT_LIGHT_REGISTRY_PATH[] = L"Software\\Microsoft\\Windows\\CurrentVersion\\CloudStore\\Store\\DefaultAccount\\Current\\default$windows.data.bluelightreduction.bluelightreductionstate\\windows.data.bluelightreduction.bluelightreductionstate"; diff --git a/src/modules/LightSwitch/LightSwitchService/ThemeHelper.cpp b/src/modules/LightSwitch/LightSwitchService/ThemeHelper.cpp index 9633ab2fde..cfa858c636 100644 --- a/src/modules/LightSwitch/LightSwitchService/ThemeHelper.cpp +++ b/src/modules/LightSwitch/LightSwitchService/ThemeHelper.cpp @@ -3,6 +3,7 @@ #include #include #include "ThemeHelper.h" +#include // Controls changing the themes. @@ -10,7 +11,7 @@ static void ResetColorPrevalence() { HKEY hKey; if (RegOpenKeyEx(HKEY_CURRENT_USER, - L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", + PERSONALIZATION_REGISTRY_PATH, 0, KEY_SET_VALUE, &hKey) == ERROR_SUCCESS) @@ -31,7 +32,7 @@ void SetAppsTheme(bool mode) { HKEY hKey; if (RegOpenKeyEx(HKEY_CURRENT_USER, - L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", + PERSONALIZATION_REGISTRY_PATH, 0, KEY_SET_VALUE, &hKey) == ERROR_SUCCESS) @@ -50,7 +51,7 @@ void SetSystemTheme(bool mode) { HKEY hKey; if (RegOpenKeyEx(HKEY_CURRENT_USER, - L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", + PERSONALIZATION_REGISTRY_PATH, 0, KEY_SET_VALUE, &hKey) == ERROR_SUCCESS) @@ -79,7 +80,7 @@ bool GetCurrentSystemTheme() DWORD size = sizeof(value); if (RegOpenKeyEx(HKEY_CURRENT_USER, - L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", + PERSONALIZATION_REGISTRY_PATH, 0, KEY_READ, &hKey) == ERROR_SUCCESS) @@ -98,7 +99,7 @@ bool GetCurrentAppsTheme() DWORD size = sizeof(value); if (RegOpenKeyEx(HKEY_CURRENT_USER, - L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", + PERSONALIZATION_REGISTRY_PATH, 0, KEY_READ, &hKey) == ERROR_SUCCESS) @@ -109,3 +110,30 @@ bool GetCurrentAppsTheme() return value == 1; // true = light, false = dark } + +bool IsNightLightEnabled() +{ + HKEY hKey; + const wchar_t* path = NIGHT_LIGHT_REGISTRY_PATH; + + if (RegOpenKeyExW(HKEY_CURRENT_USER, path, 0, KEY_READ, &hKey) != ERROR_SUCCESS) + return false; + + // RegGetValueW will set size to the size of the data and we expect that to be at least 25 bytes (we need to access bytes 23 and 24) + DWORD size = 0; + if (RegGetValueW(hKey, nullptr, L"Data", RRF_RT_REG_BINARY, nullptr, nullptr, &size) != ERROR_SUCCESS || size < 25) + { + RegCloseKey(hKey); + return false; + } + + std::vector data(size); + if (RegGetValueW(hKey, nullptr, L"Data", RRF_RT_REG_BINARY, nullptr, data.data(), &size) != ERROR_SUCCESS) + { + RegCloseKey(hKey); + return false; + } + + RegCloseKey(hKey); + return data[23] == 0x10 && data[24] == 0x00; +} \ No newline at end of file diff --git a/src/modules/LightSwitch/LightSwitchService/ThemeHelper.h b/src/modules/LightSwitch/LightSwitchService/ThemeHelper.h index 5985fd95c8..e8d45e9c2a 100644 --- a/src/modules/LightSwitch/LightSwitchService/ThemeHelper.h +++ b/src/modules/LightSwitch/LightSwitchService/ThemeHelper.h @@ -3,3 +3,4 @@ void SetSystemTheme(bool dark); void SetAppsTheme(bool dark); bool GetCurrentSystemTheme(); bool GetCurrentAppsTheme(); +bool IsNightLightEnabled(); \ No newline at end of file diff --git a/src/settings-ui/Settings.UI/Helpers/StartProcessHelper.cs b/src/settings-ui/Settings.UI/Helpers/StartProcessHelper.cs index ce172b2aa2..05c5d7f66c 100644 --- a/src/settings-ui/Settings.UI/Helpers/StartProcessHelper.cs +++ b/src/settings-ui/Settings.UI/Helpers/StartProcessHelper.cs @@ -11,6 +11,7 @@ namespace Microsoft.PowerToys.Settings.UI.Helpers { public const string ColorsSettings = "ms-settings:colors"; public const string DiagnosticsAndFeedback = "ms-settings:privacy-feedback"; + public const string NightLightSettings = "ms-settings:nightlight"; public static string AnimationsSettings => OSVersionHelper.IsWindows11() ? "ms-settings:easeofaccess-visualeffects" diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/LightSwitchPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/LightSwitchPage.xaml index ec61a0fcd5..a44d482a04 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/LightSwitchPage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/LightSwitchPage.xaml @@ -67,6 +67,10 @@ x:Uid="LightSwitch_ModeSunsetToSunrise" AutomationProperties.AutomationId="SunCBItem_LightSwitch" Tag="SunsetToSunrise" /> + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/LightSwitchPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/LightSwitchPage.xaml.cs index dcd40fdbc7..1ee79a4010 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/LightSwitchPage.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/LightSwitchPage.xaml.cs @@ -355,6 +355,10 @@ namespace Microsoft.PowerToys.Settings.UI.Views VisualStateManager.GoToState(this, "SunsetToSunriseState", true); this.SunriseModeChartState(); break; + case "FollowNightLight": + VisualStateManager.GoToState(this, "FollowNightLightState", true); + TimelineCard.Visibility = Visibility.Collapsed; + break; default: VisualStateManager.GoToState(this, "OffState", true); this.TimelineCard.Visibility = Visibility.Collapsed; @@ -362,6 +366,18 @@ namespace Microsoft.PowerToys.Settings.UI.Views } } + private void OpenNightLightSettings_Click(object sender, RoutedEventArgs e) + { + try + { + Helpers.StartProcessHelper.Start(Helpers.StartProcessHelper.NightLightSettings); + } + catch (Exception ex) + { + Logger.LogError("Error while trying to open the system night light settings", ex); + } + } + private void SunriseModeChartState() { if (this.ViewModel.Latitude != "0.0" && this.ViewModel.Longitude != "0.0") 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 f7fbeda08b..30535804b7 100644 --- a/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw +++ b/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw @@ -1,17 +1,17 @@ - @@ -5760,4 +5760,13 @@ To record a specific window, enter the hotkey with the Alt key in the opposite m A modern UI built with Fluent Design Fluent Design is a product name, do not loc - + + Follow Night Light + + + Personalize your Night Light settings. + + + Following Night Light settings. + + \ No newline at end of file diff --git a/src/settings-ui/Settings.UI/ViewModels/LightSwitchViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/LightSwitchViewModel.cs index 621fa91d43..e9e744705f 100644 --- a/src/settings-ui/Settings.UI/ViewModels/LightSwitchViewModel.cs +++ b/src/settings-ui/Settings.UI/ViewModels/LightSwitchViewModel.cs @@ -42,6 +42,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels "Off", "FixedHours", "SunsetToSunrise", + "FollowNightLight", }; _toggleThemeHotkey = _moduleSettings.Properties.ToggleThemeHotkey.Value; From 995bbdc62d92bb7357676242d076492e10a1395a Mon Sep 17 00:00:00 2001 From: Gleb Khmyznikov Date: Wed, 10 Dec 2025 10:04:04 -0800 Subject: [PATCH 2/8] Fix fancy zones UI tests #42249 (#44181) - [ ] Closes: #42249 Contribution to https://github.com/microsoft/PowerToys/issues/40701 --- .github/actions/spell-check/allow/code.txt | 4 + .../UITestAutomation/ScreenRecording.cs | 399 ++++++++++++++++++ src/common/UITestAutomation/SessionHelper.cs | 122 ++++-- .../UITestAutomation/SettingsConfigHelper.cs | 7 +- src/common/UITestAutomation/UITestBase.cs | 99 ++++- .../MouseUtils.UITests/FindMyMouseTests.cs | 2 + .../Settings/SettingsWindow.xaml.cs | 1 + .../FancyZones.UITests/DragWindowTests.cs | 286 +++++++------ .../LayoutApplyHotKeyTests.cs | 10 +- .../FancyZones.UITests/OneZoneSwitchTests.cs | 68 ++- .../peek/Peek.UITests/PeekFilePreviewTests.cs | 4 +- .../SettingsXAML/Views/FancyZonesPage.xaml | 10 +- 12 files changed, 807 insertions(+), 205 deletions(-) create mode 100644 src/common/UITestAutomation/ScreenRecording.cs diff --git a/.github/actions/spell-check/allow/code.txt b/.github/actions/spell-check/allow/code.txt index c655bb1b55..a7d02dcb21 100644 --- a/.github/actions/spell-check/allow/code.txt +++ b/.github/actions/spell-check/allow/code.txt @@ -335,3 +335,7 @@ azp feedbackhub needinfo reportbug + +#ffmpeg +crf +nostdin diff --git a/src/common/UITestAutomation/ScreenRecording.cs b/src/common/UITestAutomation/ScreenRecording.cs new file mode 100644 index 0000000000..57e844936d --- /dev/null +++ b/src/common/UITestAutomation/ScreenRecording.cs @@ -0,0 +1,399 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Drawing; +using System.Drawing.Imaging; +using System.IO; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.PowerToys.UITest +{ + /// + /// Provides methods for recording the screen during UI tests. + /// Requires FFmpeg to be installed and available in PATH. + /// + internal class ScreenRecording : IDisposable + { + private readonly string outputDirectory; + private readonly string framesDirectory; + private readonly string outputFilePath; + private readonly List capturedFrames; + private readonly SemaphoreSlim recordingLock = new(1, 1); + private readonly Stopwatch recordingStopwatch = new(); + private readonly string? ffmpegPath; + private CancellationTokenSource? recordingCancellation; + private Task? recordingTask; + private bool isRecording; + private int frameCount; + + [DllImport("user32.dll")] + private static extern IntPtr GetDC(IntPtr hWnd); + + [DllImport("gdi32.dll")] + private static extern int GetDeviceCaps(IntPtr hdc, int nIndex); + + [DllImport("user32.dll")] + private static extern IntPtr ReleaseDC(IntPtr hWnd, IntPtr hDC); + + [DllImport("user32.dll")] + private static extern bool GetCursorInfo(out ScreenCapture.CURSORINFO pci); + + [DllImport("user32.dll")] + private static extern bool DrawIconEx(IntPtr hdc, int x, int y, IntPtr hIcon, int cx, int cy, int istepIfAniCur, IntPtr hbrFlickerFreeDraw, int diFlags); + + private const int CURSORSHOWING = 0x00000001; + private const int DESKTOPHORZRES = 118; + private const int DESKTOPVERTRES = 117; + private const int DINORMAL = 0x0003; + private const int TargetFps = 15; // 15 FPS for good balance of quality and size + + /// + /// Initializes a new instance of the class. + /// + /// Directory where the recording will be saved. + public ScreenRecording(string outputDirectory) + { + this.outputDirectory = outputDirectory; + string timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss"); + framesDirectory = Path.Combine(outputDirectory, $"frames_{timestamp}"); + outputFilePath = Path.Combine(outputDirectory, $"recording_{timestamp}.mp4"); + capturedFrames = new List(); + frameCount = 0; + + // Check if FFmpeg is available + ffmpegPath = FindFfmpeg(); + if (ffmpegPath == null) + { + Console.WriteLine("FFmpeg not found. Screen recording will be disabled."); + Console.WriteLine("To enable video recording, install FFmpeg: https://ffmpeg.org/download.html"); + } + } + + /// + /// Gets a value indicating whether screen recording is available (FFmpeg found). + /// + public bool IsAvailable => ffmpegPath != null; + + /// + /// Starts recording the screen. + /// + /// A task representing the asynchronous operation. + public async Task StartRecordingAsync() + { + await recordingLock.WaitAsync(); + try + { + if (isRecording || !IsAvailable) + { + return; + } + + // Create frames directory + Directory.CreateDirectory(framesDirectory); + + recordingCancellation = new CancellationTokenSource(); + isRecording = true; + recordingStopwatch.Start(); + + // Start the recording task + recordingTask = Task.Run(() => RecordFrames(recordingCancellation.Token)); + + Console.WriteLine($"Started screen recording at {TargetFps} FPS"); + } + catch (Exception ex) + { + Console.WriteLine($"Failed to start recording: {ex.Message}"); + isRecording = false; + } + finally + { + recordingLock.Release(); + } + } + + /// + /// Stops recording and encodes video. + /// + /// A task representing the asynchronous operation. + public async Task StopRecordingAsync() + { + await recordingLock.WaitAsync(); + try + { + if (!isRecording || recordingCancellation == null) + { + return; + } + + // Signal cancellation + recordingCancellation.Cancel(); + + // Wait for recording task to complete + if (recordingTask != null) + { + await recordingTask; + } + + recordingStopwatch.Stop(); + isRecording = false; + + double duration = recordingStopwatch.Elapsed.TotalSeconds; + Console.WriteLine($"Recording stopped. Captured {capturedFrames.Count} frames in {duration:F2} seconds"); + + // Encode to video + await EncodeToVideoAsync(); + } + catch (Exception ex) + { + Console.WriteLine($"Error stopping recording: {ex.Message}"); + } + finally + { + Cleanup(); + recordingLock.Release(); + } + } + + /// + /// Records frames from the screen. + /// + private void RecordFrames(CancellationToken cancellationToken) + { + try + { + int frameInterval = 1000 / TargetFps; + var frameTimer = Stopwatch.StartNew(); + + while (!cancellationToken.IsCancellationRequested) + { + var frameStart = frameTimer.ElapsedMilliseconds; + + try + { + CaptureFrame(); + } + catch (Exception ex) + { + Console.WriteLine($"Error capturing frame: {ex.Message}"); + } + + // Sleep for remaining time to maintain target FPS + var frameTime = frameTimer.ElapsedMilliseconds - frameStart; + var sleepTime = Math.Max(0, frameInterval - (int)frameTime); + + if (sleepTime > 0) + { + Thread.Sleep(sleepTime); + } + } + } + catch (OperationCanceledException) + { + // Expected when stopping + } + catch (Exception ex) + { + Console.WriteLine($"Error during recording: {ex.Message}"); + } + } + + /// + /// Captures a single frame. + /// + private void CaptureFrame() + { + IntPtr hdc = GetDC(IntPtr.Zero); + int screenWidth = GetDeviceCaps(hdc, DESKTOPHORZRES); + int screenHeight = GetDeviceCaps(hdc, DESKTOPVERTRES); + ReleaseDC(IntPtr.Zero, hdc); + + Rectangle bounds = new Rectangle(0, 0, screenWidth, screenHeight); + using (Bitmap bitmap = new Bitmap(bounds.Width, bounds.Height, PixelFormat.Format24bppRgb)) + { + using (Graphics g = Graphics.FromImage(bitmap)) + { + g.CopyFromScreen(bounds.Location, Point.Empty, bounds.Size); + + ScreenCapture.CURSORINFO cursorInfo; + cursorInfo.CbSize = Marshal.SizeOf(); + if (GetCursorInfo(out cursorInfo) && cursorInfo.Flags == CURSORSHOWING) + { + IntPtr hdcDest = g.GetHdc(); + DrawIconEx(hdcDest, cursorInfo.PTScreenPos.X, cursorInfo.PTScreenPos.Y, cursorInfo.HCursor, 0, 0, 0, IntPtr.Zero, DINORMAL); + g.ReleaseHdc(hdcDest); + } + } + + string framePath = Path.Combine(framesDirectory, $"frame_{frameCount:D6}.jpg"); + bitmap.Save(framePath, ImageFormat.Jpeg); + capturedFrames.Add(framePath); + frameCount++; + } + } + + /// + /// Encodes captured frames to video using ffmpeg. + /// + private async Task EncodeToVideoAsync() + { + if (capturedFrames.Count == 0) + { + Console.WriteLine("No frames captured"); + return; + } + + try + { + // Build ffmpeg command with proper non-interactive flags + string inputPattern = Path.Combine(framesDirectory, "frame_%06d.jpg"); + + // -y: overwrite without asking + // -nostdin: disable interaction + // -loglevel error: only show errors + // -stats: show encoding progress + string args = $"-y -nostdin -loglevel error -stats -framerate {TargetFps} -i \"{inputPattern}\" -c:v libx264 -pix_fmt yuv420p -crf 23 \"{outputFilePath}\""; + + Console.WriteLine($"Encoding {capturedFrames.Count} frames to video..."); + + var startInfo = new ProcessStartInfo + { + FileName = ffmpegPath!, + Arguments = args, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + RedirectStandardInput = true, // Important: redirect stdin to prevent hanging + CreateNoWindow = true, + }; + + using var process = Process.Start(startInfo); + if (process != null) + { + // Close stdin immediately to ensure FFmpeg doesn't wait for input + process.StandardInput.Close(); + + // Read output streams asynchronously to prevent deadlock + var outputTask = process.StandardOutput.ReadToEndAsync(); + var errorTask = process.StandardError.ReadToEndAsync(); + + // Wait for process to exit + await process.WaitForExitAsync(); + + // Get the output + string stdout = await outputTask; + string stderr = await errorTask; + + if (process.ExitCode == 0 && File.Exists(outputFilePath)) + { + var fileInfo = new FileInfo(outputFilePath); + Console.WriteLine($"Video created: {outputFilePath} ({fileInfo.Length / 1024 / 1024:F1} MB)"); + } + else + { + Console.WriteLine($"FFmpeg encoding failed with exit code {process.ExitCode}"); + if (!string.IsNullOrWhiteSpace(stderr)) + { + Console.WriteLine($"FFmpeg error: {stderr}"); + } + } + } + } + catch (Exception ex) + { + Console.WriteLine($"Error encoding video: {ex.Message}"); + } + } + + /// + /// Finds ffmpeg executable. + /// + private static string? FindFfmpeg() + { + // Check if ffmpeg is in PATH + var pathDirs = Environment.GetEnvironmentVariable("PATH")?.Split(Path.PathSeparator) ?? Array.Empty(); + + foreach (var dir in pathDirs) + { + var ffmpegPath = Path.Combine(dir, "ffmpeg.exe"); + if (File.Exists(ffmpegPath)) + { + return ffmpegPath; + } + } + + // Check common installation locations + var commonPaths = new[] + { + @"C:\.tools\ffmpeg\bin\ffmpeg.exe", + @"C:\ffmpeg\bin\ffmpeg.exe", + @"C:\Program Files\ffmpeg\bin\ffmpeg.exe", + @"C:\Program Files (x86)\ffmpeg\bin\ffmpeg.exe", + @$"{Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData)}\Microsoft\WinGet\Links\ffmpeg.exe", + }; + + foreach (var path in commonPaths) + { + if (File.Exists(path)) + { + return path; + } + } + + return null; + } + + /// + /// Gets the path to the recorded video file. + /// + public string OutputFilePath => outputFilePath; + + /// + /// Gets the directory containing recordings. + /// + public string OutputDirectory => outputDirectory; + + /// + /// Cleans up resources. + /// + private void Cleanup() + { + recordingCancellation?.Dispose(); + recordingCancellation = null; + recordingTask = null; + + // Clean up frames directory if it exists + try + { + if (Directory.Exists(framesDirectory)) + { + Directory.Delete(framesDirectory, true); + } + } + catch (Exception ex) + { + Console.WriteLine($"Failed to cleanup frames directory: {ex.Message}"); + } + } + + /// + /// Disposes resources. + /// + public void Dispose() + { + if (isRecording) + { + StopRecordingAsync().GetAwaiter().GetResult(); + } + + Cleanup(); + recordingLock.Dispose(); + GC.SuppressFinalize(this); + } + } +} diff --git a/src/common/UITestAutomation/SessionHelper.cs b/src/common/UITestAutomation/SessionHelper.cs index 0ca3eb3ddd..fef220a647 100644 --- a/src/common/UITestAutomation/SessionHelper.cs +++ b/src/common/UITestAutomation/SessionHelper.cs @@ -130,9 +130,13 @@ namespace Microsoft.PowerToys.UITest /// /// The path to the application executable. /// Optional command line arguments to pass to the application. - public void StartExe(string appPath, string[]? args = null) + public void StartExe(string appPath, string[]? args = null, string? enableModules = null) { var opts = new AppiumOptions(); + if (!string.IsNullOrEmpty(enableModules)) + { + opts.AddAdditionalCapability("enableModules", enableModules); + } if (scope == PowerToysModule.PowerToysSettings) { @@ -169,27 +173,66 @@ namespace Microsoft.PowerToys.UITest private void TryLaunchPowerToysSettings(AppiumOptions opts) { - try + if (opts.ToCapabilities().HasCapability("enableModules")) { - var runnerProcessInfo = new ProcessStartInfo + var modulesString = (string)opts.ToCapabilities().GetCapability("enableModules"); + var modulesArray = modulesString.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + SettingsConfigHelper.ConfigureGlobalModuleSettings(modulesArray); + } + else + { + SettingsConfigHelper.ConfigureGlobalModuleSettings(); + } + + const int maxTries = 3; + const int delayMs = 5000; + const int maxRetries = 3; + + for (int tryCount = 1; tryCount <= maxTries; tryCount++) + { + try { - FileName = locationPath + runnerPath, - Verb = "runas", - Arguments = "--open-settings", - }; + var runnerProcessInfo = new ProcessStartInfo + { + FileName = locationPath + runnerPath, + Verb = "runas", + Arguments = "--open-settings", + }; - ExitExe(runnerProcessInfo.FileName); - runner = Process.Start(runnerProcessInfo); + ExitExe(runnerProcessInfo.FileName); - WaitForWindowAndSetCapability(opts, "PowerToys Settings", 5000, 5); + // Verify process was killed + string exeName = Path.GetFileNameWithoutExtension(runnerProcessInfo.FileName); + var remainingProcesses = Process.GetProcessesByName(exeName); - // Exit CmdPal UI before launching new process if use installer for test - ExitExeByName("Microsoft.CmdPal.UI"); - } - catch (Exception ex) - { - throw new InvalidOperationException($"Failed to launch PowerToys Settings: {ex.Message}", ex); + runner = Process.Start(runnerProcessInfo); + + if (WaitForWindowAndSetCapability(opts, "PowerToys Settings", delayMs, maxRetries)) + { + // Exit CmdPal UI before launching new process if use installer for test + ExitExeByName("Microsoft.CmdPal.UI"); + return; + } + + // Window not found, kill all PowerToys processes and retry + if (tryCount < maxTries) + { + KillPowerToysProcesses(); + } + } + catch (Exception ex) + { + if (tryCount == maxTries) + { + throw new InvalidOperationException($"Failed to launch PowerToys Settings after {maxTries} attempts: {ex.Message}", ex); + } + + // Kill processes and retry + KillPowerToysProcesses(); + } } + + throw new InvalidOperationException($"Failed to launch PowerToys Settings: Window not found after {maxTries} attempts."); } private void TryLaunchCommandPalette(AppiumOptions opts) @@ -211,7 +254,10 @@ namespace Microsoft.PowerToys.UITest var process = Process.Start(processStartInfo); process?.WaitForExit(); - WaitForWindowAndSetCapability(opts, "Command Palette", 5000, 10); + if (!WaitForWindowAndSetCapability(opts, "Command Palette", 5000, 10)) + { + throw new TimeoutException("Failed to find Command Palette window after multiple attempts."); + } } catch (Exception ex) { @@ -219,7 +265,7 @@ namespace Microsoft.PowerToys.UITest } } - private void WaitForWindowAndSetCapability(AppiumOptions opts, string windowName, int delayMs, int maxRetries) + private bool WaitForWindowAndSetCapability(AppiumOptions opts, string windowName, int delayMs, int maxRetries) { for (int attempt = 1; attempt <= maxRetries; attempt++) { @@ -230,18 +276,16 @@ namespace Microsoft.PowerToys.UITest { var hexHwnd = window[0].HWnd.ToString("x"); opts.AddAdditionalCapability("appTopLevelWindow", hexHwnd); - return; + return true; } if (attempt < maxRetries) { Thread.Sleep(delayMs); } - else - { - throw new TimeoutException($"Failed to find {windowName} window after multiple attempts."); - } } + + return false; } /// @@ -292,17 +336,17 @@ namespace Microsoft.PowerToys.UITest catch (Exception ex) { // Handle exceptions if needed - Debug.WriteLine($"Exception during Cleanup: {ex.Message}"); + Console.WriteLine($"Exception during Cleanup: {ex.Message}"); } } /// /// Restarts now exe and takes control of it. /// - public void RestartScopeExe() + public void RestartScopeExe(string? enableModules = null) { ExitScopeExe(); - StartExe(locationPath + sessionPath, this.commandLineArgs); + StartExe(locationPath + sessionPath, commandLineArgs, enableModules); } public WindowsDriver GetRoot() @@ -327,5 +371,31 @@ namespace Microsoft.PowerToys.UITest this.ExitExe(winAppDriverProcessInfo.FileName); SessionHelper.appDriver = Process.Start(winAppDriverProcessInfo); } + + private void KillPowerToysProcesses() + { + var powerToysProcessNames = new[] { "PowerToys", "Microsoft.CmdPal.UI" }; + + foreach (var processName in powerToysProcessNames) + { + try + { + var processes = Process.GetProcessesByName(processName); + + foreach (var process in processes) + { + process.Kill(); + process.WaitForExit(); + } + + // Verify processes are actually gone + var remainingProcesses = Process.GetProcessesByName(processName); + } + catch (Exception ex) + { + Console.WriteLine($"[KillPowerToysProcesses] Failed to kill process {processName}: {ex.Message}"); + } + } + } } } diff --git a/src/common/UITestAutomation/SettingsConfigHelper.cs b/src/common/UITestAutomation/SettingsConfigHelper.cs index 0a01891dc4..81e5e3c180 100644 --- a/src/common/UITestAutomation/SettingsConfigHelper.cs +++ b/src/common/UITestAutomation/SettingsConfigHelper.cs @@ -26,14 +26,13 @@ namespace Microsoft.PowerToys.UITest /// /// Configures global PowerToys settings to enable only specified modules and disable all others. /// - /// Array of module names to enable (e.g., "Peek", "FancyZones"). All other modules will be disabled. - /// Thrown when modulesToEnable is null. + /// Array of module names to enable (e.g., "Peek", "FancyZones"). All other modules will be disabled. If null or empty, all modules will be disabled. /// Thrown when settings file operations fail. [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "This is test code and will not be trimmed")] [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "This is test code and will not be AOT compiled")] - public static void ConfigureGlobalModuleSettings(params string[] modulesToEnable) + public static void ConfigureGlobalModuleSettings(params string[]? modulesToEnable) { - ArgumentNullException.ThrowIfNull(modulesToEnable); + modulesToEnable ??= Array.Empty(); try { diff --git a/src/common/UITestAutomation/UITestBase.cs b/src/common/UITestAutomation/UITestBase.cs index 1c72be05f4..877f384104 100644 --- a/src/common/UITestAutomation/UITestBase.cs +++ b/src/common/UITestAutomation/UITestBase.cs @@ -29,6 +29,8 @@ namespace Microsoft.PowerToys.UITest public string? ScreenshotDirectory { get; set; } + public string? RecordingDirectory { get; set; } + public static MonitorInfoData.ParamsWrapper MonitorInfoData { get; set; } = new MonitorInfoData.ParamsWrapper() { Monitors = new List() }; private readonly PowerToysModule scope; @@ -36,6 +38,7 @@ namespace Microsoft.PowerToys.UITest private readonly string[]? commandLineArgs; private SessionHelper? sessionHelper; private System.Threading.Timer? screenshotTimer; + private ScreenRecording? screenRecording; public UITestBase(PowerToysModule scope = PowerToysModule.PowerToysSettings, WindowSize size = WindowSize.UnSpecified, string[]? commandLineArgs = null) { @@ -65,12 +68,35 @@ namespace Microsoft.PowerToys.UITest CloseOtherApplications(); if (IsInPipeline) { - ScreenshotDirectory = Path.Combine(this.TestContext.TestResultsDirectory ?? string.Empty, "UITestScreenshots_" + Guid.NewGuid().ToString()); + string baseDirectory = this.TestContext.TestResultsDirectory ?? string.Empty; + ScreenshotDirectory = Path.Combine(baseDirectory, "UITestScreenshots_" + Guid.NewGuid().ToString()); Directory.CreateDirectory(ScreenshotDirectory); + RecordingDirectory = Path.Combine(baseDirectory, "UITestRecordings_" + Guid.NewGuid().ToString()); + Directory.CreateDirectory(RecordingDirectory); + // Take screenshot every 1 second screenshotTimer = new System.Threading.Timer(ScreenCapture.TimerCallback, ScreenshotDirectory, TimeSpan.Zero, TimeSpan.FromMilliseconds(1000)); + // Start screen recording (requires FFmpeg) + try + { + screenRecording = new ScreenRecording(RecordingDirectory); + if (screenRecording.IsAvailable) + { + _ = screenRecording.StartRecordingAsync(); + } + else + { + screenRecording = null; + } + } + catch (Exception ex) + { + Console.WriteLine($"Failed to start screen recording: {ex.Message}"); + screenRecording = null; + } + // Escape Popups before starting System.Windows.Forms.SendKeys.SendWait("{ESC}"); } @@ -88,15 +114,36 @@ namespace Microsoft.PowerToys.UITest if (IsInPipeline) { screenshotTimer?.Change(Timeout.Infinite, Timeout.Infinite); - Dispose(); + + // Stop screen recording + if (screenRecording != null) + { + try + { + screenRecording.StopRecordingAsync().GetAwaiter().GetResult(); + } + catch (Exception ex) + { + Console.WriteLine($"Failed to stop screen recording: {ex.Message}"); + } + } + if (TestContext.CurrentTestOutcome is UnitTestOutcome.Failed or UnitTestOutcome.Error or UnitTestOutcome.Unknown) { Task.Delay(1000).Wait(); AddScreenShotsToTestResultsDirectory(); + AddRecordingsToTestResultsDirectory(); AddLogFilesToTestResultsDirectory(); } + else + { + // Clean up recording if test passed + CleanupRecordingDirectory(); + } + + Dispose(); } this.Session.Cleanup(); @@ -106,6 +153,7 @@ namespace Microsoft.PowerToys.UITest public void Dispose() { screenshotTimer?.Dispose(); + screenRecording?.Dispose(); GC.SuppressFinalize(this); } @@ -600,6 +648,47 @@ namespace Microsoft.PowerToys.UITest } } + /// + /// Adds screen recordings to test results directory when test fails. + /// + protected void AddRecordingsToTestResultsDirectory() + { + if (RecordingDirectory != null && Directory.Exists(RecordingDirectory)) + { + // Add video files (MP4) + var videoFiles = Directory.GetFiles(RecordingDirectory, "*.mp4"); + foreach (string file in videoFiles) + { + this.TestContext.AddResultFile(file); + var fileInfo = new FileInfo(file); + Console.WriteLine($"Added video recording: {Path.GetFileName(file)} ({fileInfo.Length / 1024 / 1024:F1} MB)"); + } + + if (videoFiles.Length == 0) + { + Console.WriteLine("No video recording available (FFmpeg not found). Screenshots are still captured."); + } + } + } + + /// + /// Cleans up recording directory when test passes. + /// + private void CleanupRecordingDirectory() + { + if (RecordingDirectory != null && Directory.Exists(RecordingDirectory)) + { + try + { + Directory.Delete(RecordingDirectory, true); + } + catch (Exception ex) + { + Console.WriteLine($"Failed to cleanup recording directory: {ex.Message}"); + } + } + } + /// /// Copies PowerToys log files to test results directory when test fails. /// Renames files to include the directory structure after \PowerToys. @@ -689,11 +778,11 @@ namespace Microsoft.PowerToys.UITest /// /// Restart scope exe. /// - public void RestartScopeExe() + public Session RestartScopeExe(string? enableModules = null) { - this.sessionHelper!.RestartScopeExe(); + this.sessionHelper!.RestartScopeExe(enableModules); this.Session = new Session(this.sessionHelper.GetRoot(), this.sessionHelper.GetDriver(), this.scope, this.size); - return; + return Session; } /// diff --git a/src/modules/MouseUtils/MouseUtils.UITests/FindMyMouseTests.cs b/src/modules/MouseUtils/MouseUtils.UITests/FindMyMouseTests.cs index 7cad62decb..5f857aa391 100644 --- a/src/modules/MouseUtils/MouseUtils.UITests/FindMyMouseTests.cs +++ b/src/modules/MouseUtils/MouseUtils.UITests/FindMyMouseTests.cs @@ -617,6 +617,8 @@ namespace MouseUtils.UITests private void LaunchFromSetting(bool reload = false, bool launchAsAdmin = false) { + Session = RestartScopeExe("FindMyMouse,MouseHighlighter,MouseJump,MousePointerCrosshairs,CursorWrap"); + // this.Session.Attach(PowerToysModule.PowerToysSettings); this.Session.SetMainWindowSize(WindowSize.Large); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/SettingsWindow.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/SettingsWindow.xaml.cs index 855a3e2e6c..5b12f78542 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/SettingsWindow.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/SettingsWindow.xaml.cs @@ -105,6 +105,7 @@ public sealed partial class SettingsWindow : WindowEx, "Extensions" => typeof(ExtensionsPage), _ => null, }; + if (pageType is not null) { NavFrame.Navigate(pageType); diff --git a/src/modules/fancyzones/FancyZones.UITests/DragWindowTests.cs b/src/modules/fancyzones/FancyZones.UITests/DragWindowTests.cs index 82e05707e7..117e128734 100644 --- a/src/modules/fancyzones/FancyZones.UITests/DragWindowTests.cs +++ b/src/modules/fancyzones/FancyZones.UITests/DragWindowTests.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; using System.Windows.Forms; using FancyZonesEditor.Models; @@ -49,19 +50,16 @@ namespace UITests_FancyZones [TestInitialize] public void TestInitialize() { - // ClearOpenWindows + Session.KillAllProcessesByName("PowerToys"); ClearOpenWindows(); - // kill all processes related to FancyZones Editor to ensure a clean state - Session.KillAllProcessesByName("PowerToys.FancyZonesEditor"); - AppZoneHistory.DeleteFile(); - this.RestartScopeExe(); FancyZonesEditorHelper.Files.Restore(); - - // Set a custom layout with 1 subzones and clear app zone history SetupCustomLayouts(); + RestartScopeExe("Hosts"); + Thread.Sleep(2000); + // Get the current mouse button setting nonPrimaryMouseButton = SystemInformation.MouseButtonsSwapped ? "Left" : "Right"; @@ -72,99 +70,6 @@ namespace UITests_FancyZones LaunchFancyZones(); } - /// - /// Test Use Shift key to activate zones while dragging a window in FancyZones Zone Behaviour Settings - /// - /// - /// Verifies that holding Shift while dragging shows all zones as expected. - /// - /// - /// - [TestMethod("FancyZones.Settings.TestShowZonesOnShiftDuringDrag")] - [TestCategory("FancyZones_Dragging #1")] - public void TestShowZonesOnShiftDuringDrag() - { - string testCaseName = nameof(TestShowZonesOnShiftDuringDrag); - Pane dragElement = Find(By.Name("Non Client Input Sink Window")); // element to drag - var offSet = ZoneSwitchHelper.GetOffset(dragElement, quarterX, quarterY); - - var (initialColor, withShiftColor) = RunDragInteractions( - preAction: () => - { - dragElement.DragAndHold(offSet.Dx, offSet.Dy); - }, - postAction: () => - { - Session.PressKey(Key.Shift); - Task.Delay(500).Wait(); - }, - releaseAction: () => - { - Session.ReleaseKey(Key.Shift); - Task.Delay(5000).Wait(); // Optional: Wait for a moment to ensure window switch - }, - testCaseName: testCaseName); - - string zoneColorWithoutShift = GetOutWindowPixelColor(30); - - Assert.AreNotEqual(initialColor, withShiftColor, $"[{testCaseName}] Zone display failed."); - Assert.IsTrue( - withShiftColor == inactivateColor || withShiftColor == highlightColor, - $"[{testCaseName}] Zone display failed: withShiftColor was {withShiftColor}, expected {inactivateColor} or {highlightColor}."); - Assert.AreEqual(inactivateColor, withShiftColor, $"[{testCaseName}] Zone display failed."); - - Assert.AreEqual(zoneColorWithoutShift, initialColor, $"[{testCaseName}] Zone deactivated failed."); - dragElement.ReleaseDrag(); - - Clean(); - } - - /// - /// Test dragging a window during Shift key press in FancyZones Zone Behaviour Settings - /// - /// - /// Verifies that dragging activates zones as expected. - /// - /// - /// - [TestMethod("FancyZones.Settings.TestShowZonesOnDragDuringShift")] - [TestCategory("FancyZones_Dragging #2")] - public void TestShowZonesOnDragDuringShift() - { - string testCaseName = nameof(TestShowZonesOnDragDuringShift); - - var dragElement = Find(By.Name("Non Client Input Sink Window")); - var offSet = ZoneSwitchHelper.GetOffset(dragElement, quarterX, quarterY); - - var (initialColor, withDragColor) = RunDragInteractions( - preAction: () => - { - dragElement.Drag(offSet.Dx, offSet.Dy); - Session.PressKey(Key.Shift); - }, - postAction: () => - { - dragElement.DragAndHold(0, 0); - Task.Delay(5000).Wait(); - }, - releaseAction: () => - { - dragElement.ReleaseDrag(); - Session.ReleaseKey(Key.Shift); - }, - testCaseName: testCaseName); - - Assert.AreNotEqual(initialColor, withDragColor, $"[{testCaseName}] Zone color did not change; zone activation failed."); - Assert.AreEqual(highlightColor, withDragColor, $"[{testCaseName}] Zone color did not match the highlight color; activation failed."); - - // double check by app-zone-history.json - string appZoneHistoryJson = AppZoneHistory.GetData(); - string? zoneNumber = ZoneSwitchHelper.GetZoneIndexSetByAppName(powertoysWindowName, appZoneHistoryJson); - Assert.IsNull(zoneNumber, $"[{testCaseName}] AppZoneHistory layout was unexpectedly set."); - - Clean(); - } - /// /// Test toggling zones using a non-primary mouse click during window dragging. /// @@ -178,14 +83,19 @@ namespace UITests_FancyZones public void TestToggleZonesWithNonPrimaryMouseClick() { string testCaseName = nameof(TestToggleZonesWithNonPrimaryMouseClick); - var dragElement = Find(By.Name("Non Client Input Sink Window")); - var offSet = ZoneSwitchHelper.GetOffset(dragElement, quarterX, quarterY); + + var windowRect = Session.GetMainWindowRect(); + int startX = windowRect.Left + 70; + int startY = windowRect.Top + 25; + int endX = startX + 300; + int endY = startY + 300; var (initialColor, withMouseColor) = RunDragInteractions( preAction: () => { - // activate zone - dragElement.DragAndHold(offSet.Dx, offSet.Dy); + Session.MoveMouseTo(startX, startY); + Session.PerformMouseAction(MouseActionType.LeftDown); + Session.MoveMouseTo(endX, endY); }, postAction: () => { @@ -195,7 +105,7 @@ namespace UITests_FancyZones }, releaseAction: () => { - dragElement.ReleaseDrag(); + Session.PerformMouseAction(MouseActionType.LeftUp); }, testCaseName: testCaseName); @@ -204,8 +114,6 @@ namespace UITests_FancyZones // check the zone color is activated Assert.AreEqual(highlightColor, initialColor, $"[{testCaseName}] Zone activation failed."); - - Clean(); } /// @@ -221,32 +129,35 @@ namespace UITests_FancyZones public void TestShowZonesWhenShiftAndMouseOff() { string testCaseName = nameof(TestShowZonesWhenShiftAndMouseOff); - Pane dragElement = Find(By.Name("Non Client Input Sink Window")); - var offSet = ZoneSwitchHelper.GetOffset(dragElement, quarterX, quarterY); + + var windowRect = Session.GetMainWindowRect(); + int startX = windowRect.Left + 70; + int startY = windowRect.Top + 25; + int endX = startX + 300; + int endY = startY + 300; var (initialColor, withShiftColor) = RunDragInteractions( preAction: () => { - // activate zone - dragElement.DragAndHold(offSet.Dx, offSet.Dy); + Session.MoveMouseTo(startX, startY); + Session.PerformMouseAction(MouseActionType.LeftDown); + Session.MoveMouseTo(endX, endY); }, postAction: () => { // press Shift Key to deactivate zones Session.PressKey(Key.Shift); - Task.Delay(500).Wait(); + Task.Delay(1000).Wait(); }, releaseAction: () => { - dragElement.ReleaseDrag(); + Session.PerformMouseAction(MouseActionType.LeftUp); Session.ReleaseKey(Key.Shift); }, testCaseName: testCaseName); Assert.AreEqual(highlightColor, initialColor, $"[{testCaseName}] Zone activation failed."); Assert.AreNotEqual(highlightColor, withShiftColor, $"[{testCaseName}] Zone deactivation failed."); - - Clean(); } /// @@ -263,12 +174,17 @@ namespace UITests_FancyZones { string testCaseName = nameof(TestShowZonesWhenShiftAndMouseOn); - var dragElement = Find(By.Name("Non Client Input Sink Window")); - var offSet = ZoneSwitchHelper.GetOffset(dragElement, quarterX, quarterY); + var windowRect = Session.GetMainWindowRect(); + int startX = windowRect.Left + 70; + int startY = windowRect.Top + 25; + int endX = startX + 300; + int endY = startY + 300; var (initialColor, withShiftColor) = RunDragInteractions( preAction: () => { - dragElement.DragAndHold(offSet.Dx, offSet.Dy); + Session.MoveMouseTo(startX, startY); + Session.PerformMouseAction(MouseActionType.LeftDown); + Session.MoveMouseTo(endX, endY); }, postAction: () => { @@ -279,7 +195,7 @@ namespace UITests_FancyZones }, testCaseName: testCaseName); - Assert.AreEqual(inactivateColor, withShiftColor, $"[{testCaseName}] show zone failed."); + Assert.AreEqual(highlightColor, withShiftColor, $"[{testCaseName}] show zone failed."); Session.PerformMouseAction( nonPrimaryMouseButton == "Right" ? MouseActionType.RightClick : MouseActionType.LeftClick); @@ -288,9 +204,7 @@ namespace UITests_FancyZones Assert.AreEqual(initialColor, zoneColorWithMouse, $"[{nameof(TestShowZonesWhenShiftAndMouseOff)}] Zone deactivate failed."); Session.ReleaseKey(Key.Shift); - dragElement.ReleaseDrag(); - - Clean(); + Session.PerformMouseAction(MouseActionType.LeftUp); } /// @@ -307,8 +221,6 @@ namespace UITests_FancyZones { var pixel = GetPixelWhenMakeDraggedWindow(); Assert.AreNotEqual(pixel.PixelInWindow, pixel.TransPixel, $"[{nameof(TestMakeDraggedWindowTransparentOn)}] Window transparency failed."); - - Clean(); } /// @@ -325,14 +237,103 @@ namespace UITests_FancyZones { var pixel = GetPixelWhenMakeDraggedWindow(); Assert.AreEqual(pixel.PixelInWindow, pixel.TransPixel, $"[{nameof(TestMakeDraggedWindowTransparentOff)}] Window without transparency failed."); - - Clean(); } - private void Clean() + /// + /// Test Use Shift key to activate zones while dragging a window in FancyZones Zone Behaviour Settings + /// + /// + /// Verifies that holding Shift while dragging shows all zones as expected. + /// + /// + /// + [TestMethod("FancyZones.Settings.TestShowZonesOnShiftDuringDrag")] + [TestCategory("FancyZones_Dragging #1")] + public void TestShowZonesOnShiftDuringDrag() { - // clean app zone history file - AppZoneHistory.DeleteFile(); + string testCaseName = nameof(TestShowZonesOnShiftDuringDrag); + + var windowRect = Session.GetMainWindowRect(); + int startX = windowRect.Left + 70; + int startY = windowRect.Top + 25; + int endX = startX + 300; + int endY = startY + 300; + + var (initialColor, withShiftColor) = RunDragInteractions( + preAction: () => + { + Session.MoveMouseTo(startX, startY); + Session.PerformMouseAction(MouseActionType.LeftDown); + Session.MoveMouseTo(endX, endY); + }, + postAction: () => + { + Session.PressKey(Key.Shift); + Task.Delay(500).Wait(); + }, + releaseAction: () => + { + Session.ReleaseKey(Key.Shift); + Task.Delay(1000).Wait(); // Optional: Wait for a moment to ensure window switch + }, + testCaseName: testCaseName); + + string zoneColorWithoutShift = GetOutWindowPixelColor(30); + + Assert.AreNotEqual(initialColor, withShiftColor, $"[{testCaseName}] Zone color did not change; zone activation failed."); + Assert.AreEqual(highlightColor, withShiftColor, $"[{testCaseName}] Zone color did not match the highlight color; activation failed."); + + Session.PerformMouseAction(MouseActionType.LeftUp); + } + + /// + /// Test dragging a window during Shift key press in FancyZones Zone Behaviour Settings + /// + /// + /// Verifies that dragging activates zones as expected. + /// + /// + /// + [TestMethod("FancyZones.Settings.TestShowZonesOnDragDuringShift")] + [TestCategory("FancyZones_Dragging #2")] + public void TestShowZonesOnDragDuringShift() + { + string testCaseName = nameof(TestShowZonesOnDragDuringShift); + + var windowRect = Session.GetMainWindowRect(); + int startX = windowRect.Left + 70; + int startY = windowRect.Top + 25; + int endX = startX + 300; + int endY = startY + 300; + + var (initialColor, withDragColor) = RunDragInteractions( + preAction: () => + { + Session.PressKey(Key.Shift); + Task.Delay(100).Wait(); + }, + postAction: () => + { + Session.MoveMouseTo(startX, startY); + Session.PerformMouseAction(MouseActionType.LeftDown); + Session.MoveMouseTo(endX, endY); + Task.Delay(1000).Wait(); + }, + releaseAction: () => + { + Session.PerformMouseAction(MouseActionType.LeftUp); + Session.ReleaseKey(Key.Shift); + Task.Delay(100).Wait(); + }, + testCaseName: testCaseName); + + Assert.AreNotEqual(initialColor, withDragColor, $"[{testCaseName}] Zone color did not change; zone activation failed."); + Assert.AreEqual(highlightColor, withDragColor, $"[{testCaseName}] Zone color did not match the highlight color; activation failed."); + + // double check by app-zone-history.json + string appZoneHistoryJson = AppZoneHistory.GetData(); + string? zoneNumber = ZoneSwitchHelper.GetZoneIndexSetByAppName(powertoysWindowName, appZoneHistoryJson); + Assert.IsNull(zoneNumber, $"[{testCaseName}] AppZoneHistory layout was unexpectedly set."); } // Helper method to ensure the desktop has no open windows by clicking the "Show Desktop" button @@ -352,7 +353,7 @@ namespace UITests_FancyZones desktopButtonName = "Show Desktop"; } - this.Find(By.Name(desktopButtonName), 5000, true).Click(false, 500, 2000); + this.Find(By.Name(desktopButtonName), 5000, true).Click(false, 500, 1000); } // Setup custom layout with 1 subzones @@ -382,6 +383,11 @@ namespace UITests_FancyZones this.Scroll(6, "Down"); // Pull the settings page up to make sure the settings are visible ZoneBehaviourSettings(TestContext.TestName); + // Go back and forth to make sure settings applied + this.Find("Workspaces").Click(); + Task.Delay(200).Wait(); + this.Find("FancyZones").Click(); + this.Find(By.AccessibilityId("LaunchLayoutEditorButton")).Click(false, 500, 10000); this.Session.Attach(PowerToysModule.FancyZone); @@ -435,22 +441,26 @@ namespace UITests_FancyZones // Get the mouse color of the pixel when make dragged window private (string PixelInWindow, string TransPixel) GetPixelWhenMakeDraggedWindow() { - var dragElement = Find(By.Name("Non Client Input Sink Window")); + var windowRect = Session.GetMainWindowRect(); + int startX = windowRect.Left + 70; + int startY = windowRect.Top + 25; + int endX = startX + 100; + int endY = startY + 100; - // maximize the window to make sure get pixel color more accurate - dragElement.DoubleClick(); + Session.MoveMouseTo(startX, startY); - var offSet = ZoneSwitchHelper.GetOffset(dragElement, quarterX, quarterY); + // Session.PerformMouseAction(MouseActionType.LeftDoubleClick); Session.PressKey(Key.Shift); - dragElement.DragAndHold(offSet.Dx, offSet.Dy); - Task.Delay(1000).Wait(); // Optional: Wait for a moment to ensure the window is in position + Session.PerformMouseAction(MouseActionType.LeftDown); + Session.MoveMouseTo(endX, endY); + Tuple pos = GetMousePosition(); string pixelInWindow = this.GetPixelColorString(pos.Item1, pos.Item2); Session.ReleaseKey(Key.Shift); - Task.Delay(1000).Wait(); // Optional: Wait for a moment to ensure the window is in position + Task.Delay(1000).Wait(); string transPixel = this.GetPixelColorString(pos.Item1, pos.Item2); - dragElement.ReleaseDrag(); + Session.PerformMouseAction(MouseActionType.LeftUp); return (pixelInWindow, transPixel); } diff --git a/src/modules/fancyzones/FancyZones.UITests/LayoutApplyHotKeyTests.cs b/src/modules/fancyzones/FancyZones.UITests/LayoutApplyHotKeyTests.cs index d1712d3e2d..a145dde718 100644 --- a/src/modules/fancyzones/FancyZones.UITests/LayoutApplyHotKeyTests.cs +++ b/src/modules/fancyzones/FancyZones.UITests/LayoutApplyHotKeyTests.cs @@ -271,7 +271,7 @@ namespace UITests_FancyZones }; FancyZonesEditorHelper.Files.AppliedLayoutsIOHelper.WriteData(appliedLayouts.Serialize(appliedLayoutsWrapper)); - this.RestartScopeExe(); + RestartScopeExe("Hosts"); } [TestMethod("FancyZones.Settings.TestApplyHotKey")] @@ -598,10 +598,12 @@ namespace UITests_FancyZones this.TryReaction(); int tries = 24; Pull(tries, "down"); // Pull the setting page up to make sure the setting is visible - this.Find("Enable quick layout switch").Toggle(flag); + this.Find("FancyZonesQuickLayoutSwitch").Toggle(flag); - tries = 24; - Pull(tries, "up"); + // Go back and forth to make sure settings applied + this.Find("Workspaces").Click(); + Task.Delay(200).Wait(); + this.Find("FancyZones").Click(); } private void TryReaction() diff --git a/src/modules/fancyzones/FancyZones.UITests/OneZoneSwitchTests.cs b/src/modules/fancyzones/FancyZones.UITests/OneZoneSwitchTests.cs index 70d6935702..68989a4054 100644 --- a/src/modules/fancyzones/FancyZones.UITests/OneZoneSwitchTests.cs +++ b/src/modules/fancyzones/FancyZones.UITests/OneZoneSwitchTests.cs @@ -34,7 +34,7 @@ namespace UITests_FancyZones Session.KillAllProcessesByName("PowerToys.FancyZonesEditor"); AppZoneHistory.DeleteFile(); - this.RestartScopeExe(); + RestartScopeExe("Hosts"); FancyZonesEditorHelper.Files.Restore(); // Set a custom layout with 1 subzones and clear app zone history @@ -137,7 +137,7 @@ namespace UITests_FancyZones Task.Delay(500).Wait(); // Optional: Wait for a moment to ensure window switch activeWindowTitle = ZoneSwitchHelper.GetActiveWindowTitle(); - Assert.AreNotEqual(preWindow, activeWindowTitle); + Assert.AreEqual(postWindow, activeWindowTitle); Clean(); // close the windows } @@ -151,9 +151,23 @@ namespace UITests_FancyZones var rect = Session.GetMainWindowRect(); var (targetX, targetY) = ZoneSwitchHelper.GetScreenMargins(rect, 4); - var offSet = ZoneSwitchHelper.GetOffset(hostsView, targetX, targetY); - DragWithShift(hostsView, offSet); + // Snap first window (Hosts) to left zone using shift+drag with direct mouse movement + var hostsRect = hostsView.Rect ?? throw new InvalidOperationException("Failed to get hosts window rect"); + int hostsStartX = hostsRect.Left + 70; + int hostsStartY = hostsRect.Top + 25; + + // For a 2-column layout, left zone is at approximately 1/4 of screen width + int hostsEndX = rect.Left + (3 * (rect.Right - rect.Left) / 4); + int hostsEndY = rect.Top + ((rect.Bottom - rect.Top) / 2); + + Session.MoveMouseTo(hostsStartX, hostsStartY); + Session.PerformMouseAction(MouseActionType.LeftDown); + Session.PressKey(Key.Shift); + Session.MoveMouseTo(hostsEndX, hostsEndY); + Session.PerformMouseAction(MouseActionType.LeftUp); + Session.ReleaseKey(Key.Shift); + Task.Delay(500).Wait(); // Wait for snap to complete string preWindow = ZoneSwitchHelper.GetActiveWindowTitle(); @@ -163,11 +177,26 @@ namespace UITests_FancyZones Pane settingsView = Find(By.Name("Non Client Input Sink Window")); settingsView.DoubleClick(); // maximize the window - DragWithShift(settingsView, offSet); + var windowRect = Session.GetMainWindowRect(); + var settingsRect = settingsView.Rect ?? throw new InvalidOperationException("Failed to get settings window rect"); + int settingsStartX = settingsRect.Left + 70; + int settingsStartY = settingsRect.Top + 25; + + // For a 2-column layout, right zone is at approximately 3/4 of screen width + int settingsEndX = windowRect.Left + (3 * (windowRect.Right - windowRect.Left) / 4); + int settingsEndY = windowRect.Top + ((windowRect.Bottom - windowRect.Top) / 2); + + Session.MoveMouseTo(settingsStartX, settingsStartY); + Session.PerformMouseAction(MouseActionType.LeftDown); + Session.PressKey(Key.Shift); + Session.MoveMouseTo(settingsEndX, settingsEndY); + Session.PerformMouseAction(MouseActionType.LeftUp); + Session.ReleaseKey(Key.Shift); + Task.Delay(500).Wait(); // Wait for snap to complete string appZoneHistoryJson = AppZoneHistory.GetData(); - string? zoneIndexOfFileWindow = ZoneSwitchHelper.GetZoneIndexSetByAppName("PowerToys.Hosts.exe", appZoneHistoryJson); // explorer.exe + string? zoneIndexOfFileWindow = ZoneSwitchHelper.GetZoneIndexSetByAppName("PowerToys.Hosts.exe", appZoneHistoryJson); string? zoneIndexOfPowertoys = ZoneSwitchHelper.GetZoneIndexSetByAppName("PowerToys.Settings.exe", appZoneHistoryJson); // check the AppZoneHistory layout is set and in the same zone @@ -176,16 +205,6 @@ namespace UITests_FancyZones return (preWindow, powertoysWindowName); } - private void DragWithShift(Pane settingsView, (int Dx, int Dy) offSet) - { - Session.PressKey(Key.Shift); - settingsView.DragAndHold(offSet.Dx, offSet.Dy); - Task.Delay(1000).Wait(); // Wait for drag to start (optional) - settingsView.ReleaseDrag(); - Task.Delay(1000).Wait(); // Wait after drag (optional) - Session.ReleaseKey(Key.Shift); - } - private static readonly CustomLayouts.CustomLayoutListWrapper CustomLayoutsList = new CustomLayouts.CustomLayoutListWrapper { CustomLayouts = new List @@ -253,11 +272,14 @@ namespace UITests_FancyZones this.Scroll(9, "Down"); // Pull the setting page up to make sure the setting is visible bool switchWindowEnable = TestContext.TestName == "TestSwitchShortCutDisable" ? false : true; - this.Find("Switch between windows in the current zone").Toggle(switchWindowEnable); + this.Find("FancyZonesWindowSwitchingToggle").Toggle(switchWindowEnable); - Task.Delay(500).Wait(); // Wait for the setting to be applied - this.Scroll(9, "Up"); // Pull the setting page down to make sure the setting is visible - this.Find