[Settings] Implement singleton pattern for ShortcutConflictWindow (#42440)

## Summary
Fixes issue where multiple ShortcutConflictWindow instances could be
opened simultaneously. The window now follows the same singleton pattern
as OobeWindow - only one instance can exist at a time, and attempting to
open another brings the existing window to the foreground.

## Changes
Implemented singleton management for `ShortcutConflictWindow` following
the established pattern used by `OobeWindow`:

### App.xaml.cs
- Added static field to store the singleton window instance
- Added `GetShortcutConflictWindow()`, `SetShortcutConflictWindow()`,
and `ClearShortcutConflictWindow()` methods for lifecycle management

### ShortcutConflictWindow.xaml.cs
- Updated `WindowEx_Closed` event handler to call
`App.ClearShortcutConflictWindow()` to properly clean up the singleton
reference when the window is closed

### Updated all three entry points that create ShortcutConflictWindow:
- **ShortcutConflictControl.xaml.cs** (Dashboard conflict warning)
- **ShortcutControl.xaml.cs** (Settings page shortcut controls)
- **OobeOverview.xaml.cs** (OOBE overview page)

Each location now checks if a window already exists using
`App.GetShortcutConflictWindow()`:
- If no window exists, creates a new one and registers it via
`App.SetShortcutConflictWindow()`
- If a window already exists, simply calls `Activate()` to bring it to
the foreground

## Testing
The fix ensures that:
-  Only one ShortcutConflictWindow can be open at a time
-  Clicking the shortcut conflict button when a window is already open
activates the existing window instead of creating a duplicate
-  The window reference is properly cleared when closed, allowing a new
instance to be created in future interactions

Fixes #[issue_number]

> [!WARNING]
>
> <details>
> <summary>Firewall rules blocked me from connecting to one or more
addresses (expand for details)</summary>
>
> #### I tried to connect to the following addresses, but was blocked by
firewall rules:
>
> - `i1qvsblobprodcus353.vsblob.vsassets.io`
> - Triggering command: `dotnet build PowerToys.Settings.csproj
--configuration Release` (dns block)
>
> If you need me to access, download, or install something from one of
these locations, you can either:
>
> - Configure [Actions setup
steps](https://gh.io/copilot/actions-setup-steps) to set up my
environment, which run before the firewall is enabled
> - Add the appropriate URLs or hosts to the custom allowlist in this
repository's [Copilot coding agent
settings](https://github.com/microsoft/PowerToys/settings/copilot/coding_agent)
(admins only)
>
> </details>

<!-- START COPILOT CODING AGENT SUFFIX -->



<details>

<summary>Original prompt</summary>

> 
> ----
> 
> *This section details on the original issue you should resolve*
> 
> <issue_title>[Settings] Single Shortcuts Conflicts
window</issue_title>
> <issue_description>### Microsoft PowerToys version
> 
> 0.95.0
> 
> ### Installation method
> 
> GitHub
> 
> ### Area(s) with issue?
> 
> Settings
> 
> ### Steps to reproduce
> 
> Multiple shortcut conflicts window can be launched.
> Should have the same behavior of OOBE window.
> If shortcut conflicts window is already opened, pressing the button
should bring the window in foreground.
> 
> ### ✔️ Expected Behavior
> 
> Single shortcuts conflicts window
> 
> ###  Actual Behavior
> 
> Multiple shortcut conflicts window can be launched
> 
> ### Additional Information
> 
> _No response_
> 
> ### Other Software
> 
> _No response_</issue_description>
> 
> <agent_instructions>Settings ShortcutConflictWindow should have the
same behavior of OobeWindow.
> When ShortcutConflictWindow is already opened, activate that window
instead of opening another one. </agent_instructions>
> 
> ## Comments on the Issue (you are @copilot in this section)
> 
> <comments>
> </comments>
> 


</details>

Fixes microsoft/PowerToys#42437

<!-- START COPILOT CODING AGENT TIPS -->
---

💬 Share your feedback on Copilot coding agent for the chance to win a
$200 gift card! Click
[here](https://survey3.medallia.com/?EAHeSx-AP01bZqG0Ld9QLQ) to start
the survey.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: davidegiacometti <25966642+davidegiacometti@users.noreply.github.com>
Co-authored-by: Davide Giacometti <davide.giacometti@outlook.it>
Co-authored-by: Niels Laute <niels.laute@live.nl>
Co-authored-by: vanzue <vanzue@outlook.com>
This commit is contained in:
Copilot
2026-02-12 17:28:21 +08:00
committed by GitHub
parent 528fb524d0
commit 587385d879
7 changed files with 88 additions and 18 deletions

View File

@@ -17,6 +17,7 @@ using Microsoft.PowerToys.Settings.UI.OOBE.Enums;
using Microsoft.PowerToys.Settings.UI.OOBE.ViewModel;
using Microsoft.PowerToys.Settings.UI.SerializationContext;
using Microsoft.PowerToys.Settings.UI.Services;
using Microsoft.PowerToys.Settings.UI.SettingsXAML.Controls.Dashboard;
using Microsoft.PowerToys.Settings.UI.Views;
using Microsoft.PowerToys.Telemetry;
using Microsoft.UI.Xaml;
@@ -35,6 +36,8 @@ namespace Microsoft.PowerToys.Settings.UI
private ScoobeWindow scoobeWindow;
private ShortcutConflictWindow shortcutConflictWindow;
private enum Arguments
{
PTPipeName = 1,
@@ -336,10 +339,10 @@ namespace Microsoft.PowerToys.Settings.UI
return settingsWindow;
}
public static bool IsOobeOrScoobeOpen()
public static bool IsSecondaryWindowOpen()
{
var app = (App)Current;
return app.oobeWindow != null || app.scoobeWindow != null;
return app.oobeWindow != null || app.scoobeWindow != null || app.shortcutConflictWindow != null;
}
public void OpenScoobe()
@@ -384,6 +387,25 @@ namespace Microsoft.PowerToys.Settings.UI
}
}
public void OpenShortcutConflictWindow()
{
if (shortcutConflictWindow == null)
{
shortcutConflictWindow = new ShortcutConflictWindow();
shortcutConflictWindow.Closed += (_, _) =>
{
shortcutConflictWindow = null;
};
shortcutConflictWindow.Activate();
}
else
{
WindowHelpers.BringToForeground(shortcutConflictWindow.GetWindowHandle());
}
}
public static Type GetPage(string settingWindow)
{
switch (settingWindow)

View File

@@ -2,10 +2,7 @@
// 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.ComponentModel;
using System.Linq;
using Microsoft.PowerToys.Settings.UI.Library.HotkeyConflicts;
using Microsoft.PowerToys.Settings.UI.Library.Telemetry.Events;
using Microsoft.PowerToys.Settings.UI.SettingsXAML.Controls.Dashboard;
@@ -154,11 +151,7 @@ namespace Microsoft.PowerToys.Settings.UI.Controls
ConflictCount = this.ConflictCount,
});
// Create and show the new window instead of dialog
var conflictWindow = new ShortcutConflictWindow();
// Show the window
conflictWindow.Activate();
((App)App.Current)!.OpenShortcutConflictWindow();
}
}
}

View File

@@ -2,16 +2,13 @@
// 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 CommunityToolkit.WinUI.Controls;
using Microsoft.PowerToys.Settings.UI.Helpers;
using Microsoft.PowerToys.Settings.UI.Library;
using Microsoft.PowerToys.Settings.UI.Library.Helpers;
using Microsoft.PowerToys.Settings.UI.Library.HotkeyConflicts;
using Microsoft.PowerToys.Settings.UI.Services;
using Microsoft.PowerToys.Settings.UI.ViewModels;
using Microsoft.PowerToys.Settings.UI.Views;
using Microsoft.UI;
using Microsoft.UI.Windowing;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
@@ -26,7 +23,11 @@ namespace Microsoft.PowerToys.Settings.UI.SettingsXAML.Controls.Dashboard
public ShortcutConflictWindow()
{
App.ThemeService.ThemeChanged += OnThemeChanged;
App.ThemeService.ApplyTheme();
var settingsUtils = SettingsUtils.Default;
ViewModel = new ShortcutConflictViewModel(
settingsUtils,
SettingsRepository<GeneralSettings>.GetInstance(settingsUtils),
@@ -50,6 +51,11 @@ namespace Microsoft.PowerToys.Settings.UI.SettingsXAML.Controls.Dashboard
ViewModel.OnPageLoaded();
}
private void OnThemeChanged(object sender, ElementTheme theme)
{
WindowHelper.SetTheme(this, theme);
}
private void CenterOnScreen()
{
var displayArea = DisplayArea.GetFromWindowId(this.AppWindow.Id, DisplayAreaFallback.Nearest);
@@ -127,6 +133,14 @@ namespace Microsoft.PowerToys.Settings.UI.SettingsXAML.Controls.Dashboard
private void WindowEx_Closed(object sender, WindowEventArgs args)
{
ViewModel?.Dispose();
var mainWindow = App.GetSettingsWindow();
if (mainWindow != null)
{
mainWindow.CloseHiddenWindow();
}
App.ThemeService.ThemeChanged -= OnThemeChanged;
}
private void Window_Activated_SetIcon(object sender, WindowActivatedEventArgs args)

View File

@@ -9,7 +9,6 @@ using System.Linq;
using CommunityToolkit.WinUI;
using Microsoft.PowerToys.Settings.UI.Helpers;
using Microsoft.PowerToys.Settings.UI.Library;
using Microsoft.PowerToys.Settings.UI.Library.HotkeyConflicts;
using Microsoft.PowerToys.Settings.UI.Library.Telemetry.Events;
using Microsoft.PowerToys.Settings.UI.Services;
using Microsoft.PowerToys.Settings.UI.SettingsXAML.Controls.Dashboard;
@@ -300,9 +299,7 @@ namespace Microsoft.PowerToys.Settings.UI.Controls
// Close the current shortcut dialog
shortcutDialog.Hide();
// Create and show the ShortcutConflictWindow
var conflictWindow = new ShortcutConflictWindow();
conflictWindow.Activate();
((App)App.Current)!.OpenShortcutConflictWindow();
}
private void UpdateKeyVisualStyles()

View File

@@ -162,7 +162,7 @@ namespace Microsoft.PowerToys.Settings.UI
var hWnd = WindowNative.GetWindowHandle(this);
WindowHelper.SerializePlacement(hWnd);
if (!App.IsOobeOrScoobeOpen())
if (!App.IsSecondaryWindowOpen())
{
App.ClearSettingsWindow();
}

View File

@@ -39,6 +39,7 @@ namespace Microsoft.PowerToys.Settings.UI.Views
DataContext = ViewModel;
Loaded += (s, e) => ViewModel.OnPageLoaded();
Unloaded += (s, e) => ViewModel?.Dispose();
}
public void RefreshEnabledState()

View File

@@ -55,6 +55,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
// Flag to prevent toggle operations during sorting to avoid race conditions.
private bool _isSorting;
private bool _isDisposed;
private AllHotkeyConflictsData _allHotkeyConflictsData = new AllHotkeyConflictsData();
@@ -132,8 +133,18 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
private void OnSettingsChanged(GeneralSettings newSettings)
{
if (_isDisposed)
{
return;
}
dispatcher.TryEnqueue(() =>
{
if (_isDisposed)
{
return;
}
generalSettingsConfig = newSettings;
// Update local field and notify UI if sort order changed
@@ -149,8 +160,18 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
protected override void OnConflictsUpdated(object sender, AllHotkeyConflictsEventArgs e)
{
if (_isDisposed)
{
return;
}
dispatcher.TryEnqueue(() =>
{
if (_isDisposed)
{
return;
}
var allConflictData = e.Conflicts;
foreach (var inAppConflict in allConflictData.InAppConflicts)
{
@@ -363,6 +384,11 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
/// </summary>
public void ModuleEnabledChangedOnSettingsPage()
{
if (_isDisposed)
{
return;
}
// Ignore if this was triggered by a UI change that we're already handling.
if (_isUpdatingFromUI)
{
@@ -391,6 +417,17 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
/// </summary>
private void RefreshShortcutModules()
{
if (_isDisposed)
{
return;
}
if (!dispatcher.HasThreadAccess)
{
_ = dispatcher.TryEnqueue(DispatcherQueuePriority.Normal, RefreshShortcutModules);
return;
}
ShortcutModules.Clear();
ActionModules.Clear();
@@ -804,6 +841,12 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
public override void Dispose()
{
if (_isDisposed)
{
return;
}
_isDisposed = true;
base.Dispose();
if (_settingsRepository != null)
{