mirror of
https://github.com/microsoft/PowerToys.git
synced 2025-12-16 03:37:59 +01:00
Add DisplayChangeWatcher for monitor hot-plug detection
Introduced the `DisplayChangeWatcher` component to detect monitor connect/disconnect events using the WinRT `DeviceWatcher` API. Implemented 1-second debouncing to coalesce rapid changes and trigger a `DisplayChanged` event for refreshing the monitor list. Integrated `DisplayChangeWatcher` into `MainViewModel`, adding lifecycle management methods (`StartDisplayWatching`, `StopDisplayWatching`) and handling the `DisplayChanged` event to refresh monitors dynamically. Updated `Dispose` in `MainViewModel` to ensure proper cleanup of the `DisplayChangeWatcher`. Enhanced logging and added detailed comments for better traceability. Modified `design.md` to document the new component, updated flowcharts, and marked "Monitor Hot-Plug" as implemented. Reflected changes in the `PowerDisplay` directory structure.
This commit is contained in:
@@ -173,28 +173,51 @@ src/modules/powerdisplay/
|
||||
│ │ └── IMonitorController.cs # Controller abstraction
|
||||
│ ├── Models/
|
||||
│ │ ├── Monitor.cs # Runtime monitor data
|
||||
│ │ ├── MonitorOperationResult.cs # Operation result enum
|
||||
│ │ ├── PowerDisplayProfile.cs # Profile definition
|
||||
│ │ ├── PowerDisplayProfiles.cs # Profile collection
|
||||
│ │ └── ProfileMonitorSetting.cs # Per-monitor settings
|
||||
│ ├── Services/
|
||||
│ │ ├── ProfileService.cs # Profile persistence
|
||||
│ │ ├── MonitorStateManager.cs # State persistence
|
||||
│ │ └── LightSwitchListener.cs # Theme change listener
|
||||
│ │ ├── LightSwitchListener.cs # Theme change listener
|
||||
│ │ ├── MonitorStateManager.cs # State persistence (debounced)
|
||||
│ │ └── ProfileService.cs # Profile persistence
|
||||
│ └── Utils/
|
||||
│ ├── ColorTemperatureHelper.cs # Color temp utilities
|
||||
│ ├── MccsCapabilitiesParser.cs # DDC/CI capabilities parser
|
||||
│ └── ColorTemperatureHelper.cs
|
||||
│ └── VcpCapabilities.cs # VCP capabilities model
|
||||
│
|
||||
├── PowerDisplay/ # WinUI 3 application
|
||||
│ ├── Core/
|
||||
│ │ └── MonitorManager.cs # Discovery orchestrator
|
||||
│ ├── Assets/ # App icons and images
|
||||
│ ├── Common/
|
||||
│ │ ├── Debouncer/
|
||||
│ │ │ └── SimpleDebouncer.cs # Slider input debouncing
|
||||
│ │ └── Models/
|
||||
│ │ └── Monitor.cs # UI-layer monitor model
|
||||
│ ├── Converters/ # XAML value converters
|
||||
│ ├── Helpers/
|
||||
│ │ ├── DisplayChangeWatcher.cs # Monitor hot-plug detection (WinRT DeviceWatcher)
|
||||
│ │ ├── DisplayRotationService.cs # Display rotation control
|
||||
│ │ ├── MonitorManager.cs # Discovery orchestrator
|
||||
│ │ ├── NativeMethodsHelper.cs # Window positioning
|
||||
│ │ ├── TrayIconService.cs # System tray integration
|
||||
│ │ └── WindowHelpers.cs # Window utilities
|
||||
│ ├── Strings/ # Localization resources
|
||||
│ ├── Styles/ # Custom control styles
|
||||
│ ├── ViewModels/
|
||||
│ │ ├── MainViewModel.cs
|
||||
│ │ └── MonitorViewModel.cs
|
||||
│ │ ├── MainViewModel.cs # Main VM (partial class)
|
||||
│ │ ├── MainViewModel.Monitors.cs # Monitor discovery methods
|
||||
│ │ ├── MainViewModel.Profiles.cs # Profile management methods
|
||||
│ │ ├── MainViewModel.Settings.cs # Settings persistence methods
|
||||
│ │ └── MonitorViewModel.cs # Per-monitor VM
|
||||
│ └── Views/
|
||||
│ └── MainWindow.xaml
|
||||
│ ├── MainWindow.xaml # Main UI window
|
||||
│ └── MainWindow.xaml.cs
|
||||
│
|
||||
└── PowerDisplayModuleInterface/ # C++ DLL (module interface)
|
||||
├── dllmain.cpp # PowertoyModuleIface impl
|
||||
└── Constants.h # Module constants
|
||||
├── Constants.h # Module constants
|
||||
├── pch.h / pch.cpp # Precompiled headers
|
||||
└── trace.h / trace.cpp # Telemetry tracing
|
||||
```
|
||||
|
||||
---
|
||||
@@ -221,6 +244,7 @@ flowchart TB
|
||||
MainViewModel
|
||||
MonitorViewModel
|
||||
MonitorManager
|
||||
DisplayChangeWatcher["DisplayChangeWatcher<br/>(Hot-Plug Detection)"]
|
||||
end
|
||||
|
||||
subgraph PowerDisplayLib["PowerDisplay.Lib"]
|
||||
@@ -259,6 +283,7 @@ flowchart TB
|
||||
LightSwitchListener -.->|"ThemeChanged event"| MainViewModel
|
||||
MainViewModel --> MonitorViewModel
|
||||
MonitorViewModel --> MonitorManager
|
||||
DisplayChangeWatcher -.->|"DisplayChanged event"| MainViewModel
|
||||
|
||||
%% App to Lib services
|
||||
MainViewModel --> ProfileService
|
||||
@@ -287,6 +312,35 @@ flowchart TB
|
||||
|
||||
---
|
||||
|
||||
### DisplayChangeWatcher - Monitor Hot-Plug Detection
|
||||
|
||||
The `DisplayChangeWatcher` component provides automatic detection of monitor connect/disconnect events using the WinRT DeviceWatcher API.
|
||||
|
||||
**Key Features:**
|
||||
- Uses `DisplayMonitor.GetDeviceSelector()` to watch for display device changes
|
||||
- Implements 1-second debouncing to coalesce rapid connect/disconnect events
|
||||
- Triggers `DisplayChanged` event to notify `MainViewModel` for monitor list refresh
|
||||
- Runs continuously after initial monitor discovery completes
|
||||
|
||||
**Implementation Details:**
|
||||
```csharp
|
||||
// Device selector for display monitors
|
||||
string selector = DisplayMonitor.GetDeviceSelector();
|
||||
_deviceWatcher = DeviceInformation.CreateWatcher(selector);
|
||||
|
||||
// Events monitored
|
||||
_deviceWatcher.Added += OnDeviceAdded; // New monitor connected
|
||||
_deviceWatcher.Removed += OnDeviceRemoved; // Monitor disconnected
|
||||
_deviceWatcher.Updated += OnDeviceUpdated; // Monitor properties changed
|
||||
```
|
||||
|
||||
**Debouncing Strategy:**
|
||||
- Each device change event schedules a `DisplayChanged` event after 1 second
|
||||
- Subsequent events within the debounce window cancel the previous timer
|
||||
- This prevents excessive refreshes when multiple monitors change simultaneously
|
||||
|
||||
---
|
||||
|
||||
### DDC/CI and WMI Interaction Architecture
|
||||
|
||||
```mermaid
|
||||
@@ -620,7 +674,8 @@ flowchart TB
|
||||
|
||||
InitLoop --> UpdateCollection["Update _monitors Collection"]
|
||||
UpdateCollection --> FireEvent["Fire MonitorsChanged Event"]
|
||||
FireEvent --> End([Discovery Complete])
|
||||
FireEvent --> StartWatcher["Start DisplayChangeWatcher"]
|
||||
StartWatcher --> End([Discovery Complete])
|
||||
|
||||
style ParallelDiscover fill:#e3f2fd
|
||||
style InitLoop fill:#e8f5e9
|
||||
@@ -1058,11 +1113,12 @@ classDiagram
|
||||
|
||||
1. **Hardware Cursor Brightness**: Support for displays with hardware cursor brightness
|
||||
2. **Multi-GPU Support**: Better handling of monitors across different GPUs
|
||||
3. **Monitor Hot-Plug**: Improved detection and recovery for monitor connect/disconnect
|
||||
3. ~~**Monitor Hot-Plug**: Improved detection and recovery for monitor connect/disconnect~~ **Implemented** - `DisplayChangeWatcher` uses WinRT DeviceWatcher + DisplayMonitor API with 1-second debouncing
|
||||
4. **Advanced Color Management**: Integration with Windows Color Management
|
||||
5. **Scheduled Profiles**: Time-based automatic profile switching (beyond LightSwitch)
|
||||
6. **Monitor Groups**: Ability to control multiple monitors as a single entity
|
||||
7. **Remote Control**: Network-based control for multi-system setups
|
||||
8. ~~**Display Rotation**: Control display orientation~~ **Implemented** - `DisplayRotationService` uses Windows ChangeDisplaySettingsEx API
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
// 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.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ManagedCommon;
|
||||
using Microsoft.UI.Dispatching;
|
||||
using Windows.Devices.Display;
|
||||
using Windows.Devices.Enumeration;
|
||||
|
||||
namespace PowerDisplay.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Watches for display/monitor connection changes using WinRT DeviceWatcher.
|
||||
/// Triggers DisplayChanged event when monitors are added, removed, or updated.
|
||||
/// </summary>
|
||||
public sealed class DisplayChangeWatcher : IDisposable
|
||||
{
|
||||
private readonly DispatcherQueue _dispatcherQueue;
|
||||
private readonly TimeSpan _debounceDelay = TimeSpan.FromSeconds(1);
|
||||
|
||||
private DeviceWatcher? _deviceWatcher;
|
||||
private CancellationTokenSource? _debounceCts;
|
||||
private bool _isRunning;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Event triggered when display configuration changes (after debounce period).
|
||||
/// </summary>
|
||||
public event EventHandler? DisplayChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DisplayChangeWatcher"/> class.
|
||||
/// </summary>
|
||||
/// <param name="dispatcherQueue">The dispatcher queue for UI thread marshalling.</param>
|
||||
public DisplayChangeWatcher(DispatcherQueue dispatcherQueue)
|
||||
{
|
||||
_dispatcherQueue = dispatcherQueue ?? throw new ArgumentNullException(nameof(dispatcherQueue));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the watcher is currently running.
|
||||
/// </summary>
|
||||
public bool IsRunning => _isRunning;
|
||||
|
||||
/// <summary>
|
||||
/// Starts watching for display changes.
|
||||
/// </summary>
|
||||
public void Start()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
if (_isRunning)
|
||||
{
|
||||
Logger.LogDebug("[DisplayChangeWatcher] Already running, ignoring Start()");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Get the device selector for display monitors
|
||||
string selector = DisplayMonitor.GetDeviceSelector();
|
||||
Logger.LogInfo($"[DisplayChangeWatcher] Using device selector: {selector}");
|
||||
|
||||
// Create the device watcher
|
||||
_deviceWatcher = DeviceInformation.CreateWatcher(selector);
|
||||
|
||||
// Subscribe to events
|
||||
_deviceWatcher.Added += OnDeviceAdded;
|
||||
_deviceWatcher.Removed += OnDeviceRemoved;
|
||||
_deviceWatcher.Updated += OnDeviceUpdated;
|
||||
_deviceWatcher.EnumerationCompleted += OnEnumerationCompleted;
|
||||
_deviceWatcher.Stopped += OnWatcherStopped;
|
||||
|
||||
// Start watching
|
||||
_deviceWatcher.Start();
|
||||
_isRunning = true;
|
||||
|
||||
Logger.LogInfo("[DisplayChangeWatcher] Started watching for display changes");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError($"[DisplayChangeWatcher] Failed to start: {ex.Message}");
|
||||
_isRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops watching for display changes.
|
||||
/// </summary>
|
||||
public void Stop()
|
||||
{
|
||||
if (!_isRunning || _deviceWatcher == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Cancel any pending debounce
|
||||
CancelDebounce();
|
||||
|
||||
// Stop the watcher
|
||||
_deviceWatcher.Stop();
|
||||
|
||||
Logger.LogInfo("[DisplayChangeWatcher] Stopped watching for display changes");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError($"[DisplayChangeWatcher] Error stopping watcher: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDeviceAdded(DeviceWatcher sender, DeviceInformation args)
|
||||
{
|
||||
Logger.LogInfo($"[DisplayChangeWatcher] Display added: {args.Name} ({args.Id})");
|
||||
ScheduleDisplayChanged();
|
||||
}
|
||||
|
||||
private void OnDeviceRemoved(DeviceWatcher sender, DeviceInformationUpdate args)
|
||||
{
|
||||
Logger.LogInfo($"[DisplayChangeWatcher] Display removed: {args.Id}");
|
||||
ScheduleDisplayChanged();
|
||||
}
|
||||
|
||||
private void OnDeviceUpdated(DeviceWatcher sender, DeviceInformationUpdate args)
|
||||
{
|
||||
Logger.LogDebug($"[DisplayChangeWatcher] Display updated: {args.Id}");
|
||||
|
||||
// Only trigger refresh for significant updates, not every property change.
|
||||
// For now, we'll skip updates to avoid excessive refreshes.
|
||||
// The Added and Removed events are the primary triggers for monitor changes.
|
||||
}
|
||||
|
||||
private void OnEnumerationCompleted(DeviceWatcher sender, object args)
|
||||
{
|
||||
Logger.LogInfo("[DisplayChangeWatcher] Initial enumeration completed");
|
||||
|
||||
// Don't trigger refresh on initial enumeration - MainViewModel handles initial discovery
|
||||
}
|
||||
|
||||
private void OnWatcherStopped(DeviceWatcher sender, object args)
|
||||
{
|
||||
_isRunning = false;
|
||||
Logger.LogInfo("[DisplayChangeWatcher] Watcher stopped");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schedules a DisplayChanged event with debouncing.
|
||||
/// Multiple rapid changes will only trigger one event after the debounce period.
|
||||
/// </summary>
|
||||
private void ScheduleDisplayChanged()
|
||||
{
|
||||
// Cancel any pending debounce
|
||||
CancelDebounce();
|
||||
|
||||
// Create new cancellation token
|
||||
_debounceCts = new CancellationTokenSource();
|
||||
var token = _debounceCts.Token;
|
||||
|
||||
// Schedule the event after debounce delay
|
||||
Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(_debounceDelay, token);
|
||||
|
||||
if (!token.IsCancellationRequested)
|
||||
{
|
||||
// Dispatch to UI thread
|
||||
_dispatcherQueue.TryEnqueue(() =>
|
||||
{
|
||||
if (!_disposed)
|
||||
{
|
||||
Logger.LogInfo("[DisplayChangeWatcher] Triggering DisplayChanged event");
|
||||
DisplayChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Debounce was cancelled by a newer event, this is expected
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError($"[DisplayChangeWatcher] Error in debounce task: {ex.Message}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void CancelDebounce()
|
||||
{
|
||||
try
|
||||
{
|
||||
_debounceCts?.Cancel();
|
||||
_debounceCts?.Dispose();
|
||||
_debounceCts = null;
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// Already disposed, ignore
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposes resources used by the watcher.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
|
||||
// Stop watching
|
||||
Stop();
|
||||
|
||||
// Unsubscribe from events
|
||||
if (_deviceWatcher != null)
|
||||
{
|
||||
_deviceWatcher.Added -= OnDeviceAdded;
|
||||
_deviceWatcher.Removed -= OnDeviceRemoved;
|
||||
_deviceWatcher.Updated -= OnDeviceUpdated;
|
||||
_deviceWatcher.EnumerationCompleted -= OnEnumerationCompleted;
|
||||
_deviceWatcher.Stopped -= OnWatcherStopped;
|
||||
_deviceWatcher = null;
|
||||
}
|
||||
|
||||
// Cancel debounce
|
||||
CancelDebounce();
|
||||
|
||||
Logger.LogInfo("[DisplayChangeWatcher] Disposed");
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,9 @@ public partial class MainViewModel
|
||||
IsScanning = false;
|
||||
IsInitialized = true;
|
||||
|
||||
// Start watching for display changes after initialization
|
||||
StartDisplayWatching();
|
||||
|
||||
if (monitors.Count > 0)
|
||||
{
|
||||
StatusText = $"Found {monitors.Count} monitors";
|
||||
|
||||
@@ -38,6 +38,7 @@ public partial class MainViewModel : INotifyPropertyChanged, IDisposable
|
||||
private readonly ISettingsUtils _settingsUtils;
|
||||
private readonly MonitorStateManager _stateManager;
|
||||
private readonly LightSwitchListener _lightSwitchListener;
|
||||
private readonly DisplayChangeWatcher _displayChangeWatcher;
|
||||
|
||||
private ObservableCollection<MonitorViewModel> _monitors;
|
||||
private ObservableCollection<PowerDisplayProfile> _profiles;
|
||||
@@ -78,6 +79,10 @@ public partial class MainViewModel : INotifyPropertyChanged, IDisposable
|
||||
// Load profiles for quick apply feature
|
||||
LoadProfiles();
|
||||
|
||||
// Initialize display change watcher for auto-refresh on monitor plug/unplug
|
||||
_displayChangeWatcher = new DisplayChangeWatcher(_dispatcherQueue);
|
||||
_displayChangeWatcher.DisplayChanged += OnDisplayChanged;
|
||||
|
||||
// Start initial discovery
|
||||
_ = InitializeAsync(_cancellationTokenSource.Token);
|
||||
}
|
||||
@@ -236,6 +241,7 @@ public partial class MainViewModel : INotifyPropertyChanged, IDisposable
|
||||
_cancellationTokenSource?.Cancel();
|
||||
|
||||
// Dispose all resources safely (don't throw from Dispose)
|
||||
SafeDispose(_displayChangeWatcher, "DisplayChangeWatcher");
|
||||
SafeDispose(_lightSwitchListener, "LightSwitchListener");
|
||||
|
||||
// Dispose monitor view models
|
||||
@@ -304,4 +310,30 @@ public partial class MainViewModel : INotifyPropertyChanged, IDisposable
|
||||
Logger.LogError($"[Profile] Failed to load profiles: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles display configuration changes detected by the DisplayChangeWatcher.
|
||||
/// Triggers a monitor refresh to update the UI.
|
||||
/// </summary>
|
||||
private async void OnDisplayChanged(object? sender, EventArgs e)
|
||||
{
|
||||
Logger.LogInfo("[MainViewModel] Display change detected, refreshing monitors...");
|
||||
await RefreshMonitorsAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts watching for display changes. Call after initialization is complete.
|
||||
/// </summary>
|
||||
public void StartDisplayWatching()
|
||||
{
|
||||
_displayChangeWatcher.Start();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops watching for display changes.
|
||||
/// </summary>
|
||||
public void StopDisplayWatching()
|
||||
{
|
||||
_displayChangeWatcher.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user