diff --git a/doc/devdocs/modules/powerdisplay/design.md b/doc/devdocs/modules/powerdisplay/design.md index 4886970330..18cbf99de5 100644 --- a/doc/devdocs/modules/powerdisplay/design.md +++ b/doc/devdocs/modules/powerdisplay/design.md @@ -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
(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 --- diff --git a/src/modules/powerdisplay/PowerDisplay/Helpers/DisplayChangeWatcher.cs b/src/modules/powerdisplay/PowerDisplay/Helpers/DisplayChangeWatcher.cs new file mode 100644 index 0000000000..db3ee49f3d --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/Helpers/DisplayChangeWatcher.cs @@ -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; + +/// +/// Watches for display/monitor connection changes using WinRT DeviceWatcher. +/// Triggers DisplayChanged event when monitors are added, removed, or updated. +/// +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; + + /// + /// Event triggered when display configuration changes (after debounce period). + /// + public event EventHandler? DisplayChanged; + + /// + /// Initializes a new instance of the class. + /// + /// The dispatcher queue for UI thread marshalling. + public DisplayChangeWatcher(DispatcherQueue dispatcherQueue) + { + _dispatcherQueue = dispatcherQueue ?? throw new ArgumentNullException(nameof(dispatcherQueue)); + } + + /// + /// Gets a value indicating whether the watcher is currently running. + /// + public bool IsRunning => _isRunning; + + /// + /// Starts watching for display changes. + /// + 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; + } + } + + /// + /// Stops watching for display changes. + /// + 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"); + } + + /// + /// Schedules a DisplayChanged event with debouncing. + /// Multiple rapid changes will only trigger one event after the debounce period. + /// + 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 + } + } + + /// + /// Disposes resources used by the watcher. + /// + 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"); + } +} diff --git a/src/modules/powerdisplay/PowerDisplay/ViewModels/MainViewModel.Monitors.cs b/src/modules/powerdisplay/PowerDisplay/ViewModels/MainViewModel.Monitors.cs index f2dcbda02f..41dffda9e6 100644 --- a/src/modules/powerdisplay/PowerDisplay/ViewModels/MainViewModel.Monitors.cs +++ b/src/modules/powerdisplay/PowerDisplay/ViewModels/MainViewModel.Monitors.cs @@ -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"; diff --git a/src/modules/powerdisplay/PowerDisplay/ViewModels/MainViewModel.cs b/src/modules/powerdisplay/PowerDisplay/ViewModels/MainViewModel.cs index f8827e3a00..d3b79a363f 100644 --- a/src/modules/powerdisplay/PowerDisplay/ViewModels/MainViewModel.cs +++ b/src/modules/powerdisplay/PowerDisplay/ViewModels/MainViewModel.cs @@ -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 _monitors; private ObservableCollection _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}"); } } + + /// + /// Handles display configuration changes detected by the DisplayChangeWatcher. + /// Triggers a monitor refresh to update the UI. + /// + private async void OnDisplayChanged(object? sender, EventArgs e) + { + Logger.LogInfo("[MainViewModel] Display change detected, refreshing monitors..."); + await RefreshMonitorsAsync(); + } + + /// + /// Starts watching for display changes. Call after initialization is complete. + /// + public void StartDisplayWatching() + { + _displayChangeWatcher.Start(); + } + + /// + /// Stops watching for display changes. + /// + public void StopDisplayWatching() + { + _displayChangeWatcher.Stop(); + } }