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:
Yu Leng
2025-12-05 00:10:26 +08:00
parent cf727e8a92
commit 47638c5c6d
4 changed files with 342 additions and 12 deletions

View File

@@ -173,28 +173,51 @@ src/modules/powerdisplay/
│ │ └── IMonitorController.cs # Controller abstraction │ │ └── IMonitorController.cs # Controller abstraction
│ ├── Models/ │ ├── Models/
│ │ ├── Monitor.cs # Runtime monitor data │ │ ├── Monitor.cs # Runtime monitor data
│ │ ├── MonitorOperationResult.cs # Operation result enum
│ │ ├── PowerDisplayProfile.cs # Profile definition │ │ ├── PowerDisplayProfile.cs # Profile definition
│ │ ├── PowerDisplayProfiles.cs # Profile collection
│ │ └── ProfileMonitorSetting.cs # Per-monitor settings │ │ └── ProfileMonitorSetting.cs # Per-monitor settings
│ ├── Services/ │ ├── Services/
│ │ ├── ProfileService.cs # Profile persistence │ │ ├── LightSwitchListener.cs # Theme change listener
│ │ ├── MonitorStateManager.cs # State persistence │ │ ├── MonitorStateManager.cs # State persistence (debounced)
│ │ └── LightSwitchListener.cs # Theme change listener │ │ └── ProfileService.cs # Profile persistence
│ └── Utils/ │ └── Utils/
│ ├── ColorTemperatureHelper.cs # Color temp utilities
│ ├── MccsCapabilitiesParser.cs # DDC/CI capabilities parser │ ├── MccsCapabilitiesParser.cs # DDC/CI capabilities parser
│ └── ColorTemperatureHelper.cs │ └── VcpCapabilities.cs # VCP capabilities model
├── PowerDisplay/ # WinUI 3 application ├── PowerDisplay/ # WinUI 3 application
│ ├── Core/ │ ├── Assets/ # App icons and images
│ └── MonitorManager.cs # Discovery orchestrator ├── 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/ │ ├── ViewModels/
│ │ ├── MainViewModel.cs │ │ ├── MainViewModel.cs # Main VM (partial class)
│ │ ── MonitorViewModel.cs │ │ ── MainViewModel.Monitors.cs # Monitor discovery methods
│ │ ├── MainViewModel.Profiles.cs # Profile management methods
│ │ ├── MainViewModel.Settings.cs # Settings persistence methods
│ │ └── MonitorViewModel.cs # Per-monitor VM
│ └── Views/ │ └── Views/
── MainWindow.xaml ── MainWindow.xaml # Main UI window
│ └── MainWindow.xaml.cs
└── PowerDisplayModuleInterface/ # C++ DLL (module interface) └── PowerDisplayModuleInterface/ # C++ DLL (module interface)
├── dllmain.cpp # PowertoyModuleIface impl ├── 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 MainViewModel
MonitorViewModel MonitorViewModel
MonitorManager MonitorManager
DisplayChangeWatcher["DisplayChangeWatcher<br/>(Hot-Plug Detection)"]
end end
subgraph PowerDisplayLib["PowerDisplay.Lib"] subgraph PowerDisplayLib["PowerDisplay.Lib"]
@@ -259,6 +283,7 @@ flowchart TB
LightSwitchListener -.->|"ThemeChanged event"| MainViewModel LightSwitchListener -.->|"ThemeChanged event"| MainViewModel
MainViewModel --> MonitorViewModel MainViewModel --> MonitorViewModel
MonitorViewModel --> MonitorManager MonitorViewModel --> MonitorManager
DisplayChangeWatcher -.->|"DisplayChanged event"| MainViewModel
%% App to Lib services %% App to Lib services
MainViewModel --> ProfileService 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 ### DDC/CI and WMI Interaction Architecture
```mermaid ```mermaid
@@ -620,7 +674,8 @@ flowchart TB
InitLoop --> UpdateCollection["Update _monitors Collection"] InitLoop --> UpdateCollection["Update _monitors Collection"]
UpdateCollection --> FireEvent["Fire MonitorsChanged Event"] UpdateCollection --> FireEvent["Fire MonitorsChanged Event"]
FireEvent --> End([Discovery Complete]) FireEvent --> StartWatcher["Start DisplayChangeWatcher"]
StartWatcher --> End([Discovery Complete])
style ParallelDiscover fill:#e3f2fd style ParallelDiscover fill:#e3f2fd
style InitLoop fill:#e8f5e9 style InitLoop fill:#e8f5e9
@@ -1058,11 +1113,12 @@ classDiagram
1. **Hardware Cursor Brightness**: Support for displays with hardware cursor brightness 1. **Hardware Cursor Brightness**: Support for displays with hardware cursor brightness
2. **Multi-GPU Support**: Better handling of monitors across different GPUs 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 4. **Advanced Color Management**: Integration with Windows Color Management
5. **Scheduled Profiles**: Time-based automatic profile switching (beyond LightSwitch) 5. **Scheduled Profiles**: Time-based automatic profile switching (beyond LightSwitch)
6. **Monitor Groups**: Ability to control multiple monitors as a single entity 6. **Monitor Groups**: Ability to control multiple monitors as a single entity
7. **Remote Control**: Network-based control for multi-system setups 7. **Remote Control**: Network-based control for multi-system setups
8. ~~**Display Rotation**: Control display orientation~~ **Implemented** - `DisplayRotationService` uses Windows ChangeDisplaySettingsEx API
--- ---

View File

@@ -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");
}
}

View File

@@ -39,6 +39,9 @@ public partial class MainViewModel
IsScanning = false; IsScanning = false;
IsInitialized = true; IsInitialized = true;
// Start watching for display changes after initialization
StartDisplayWatching();
if (monitors.Count > 0) if (monitors.Count > 0)
{ {
StatusText = $"Found {monitors.Count} monitors"; StatusText = $"Found {monitors.Count} monitors";

View File

@@ -38,6 +38,7 @@ public partial class MainViewModel : INotifyPropertyChanged, IDisposable
private readonly ISettingsUtils _settingsUtils; private readonly ISettingsUtils _settingsUtils;
private readonly MonitorStateManager _stateManager; private readonly MonitorStateManager _stateManager;
private readonly LightSwitchListener _lightSwitchListener; private readonly LightSwitchListener _lightSwitchListener;
private readonly DisplayChangeWatcher _displayChangeWatcher;
private ObservableCollection<MonitorViewModel> _monitors; private ObservableCollection<MonitorViewModel> _monitors;
private ObservableCollection<PowerDisplayProfile> _profiles; private ObservableCollection<PowerDisplayProfile> _profiles;
@@ -78,6 +79,10 @@ public partial class MainViewModel : INotifyPropertyChanged, IDisposable
// Load profiles for quick apply feature // Load profiles for quick apply feature
LoadProfiles(); LoadProfiles();
// Initialize display change watcher for auto-refresh on monitor plug/unplug
_displayChangeWatcher = new DisplayChangeWatcher(_dispatcherQueue);
_displayChangeWatcher.DisplayChanged += OnDisplayChanged;
// Start initial discovery // Start initial discovery
_ = InitializeAsync(_cancellationTokenSource.Token); _ = InitializeAsync(_cancellationTokenSource.Token);
} }
@@ -236,6 +241,7 @@ public partial class MainViewModel : INotifyPropertyChanged, IDisposable
_cancellationTokenSource?.Cancel(); _cancellationTokenSource?.Cancel();
// Dispose all resources safely (don't throw from Dispose) // Dispose all resources safely (don't throw from Dispose)
SafeDispose(_displayChangeWatcher, "DisplayChangeWatcher");
SafeDispose(_lightSwitchListener, "LightSwitchListener"); SafeDispose(_lightSwitchListener, "LightSwitchListener");
// Dispose monitor view models // Dispose monitor view models
@@ -304,4 +310,30 @@ public partial class MainViewModel : INotifyPropertyChanged, IDisposable
Logger.LogError($"[Profile] Failed to load profiles: {ex.Message}"); 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();
}
} }