MainViewModel.cs
1 // Copyright (c) Microsoft Corporation 2 // The Microsoft Corporation licenses this file to you under the MIT license. 3 // See the LICENSE file in the project root for more information. 4 5 using System; 6 using System.Collections.Generic; 7 using System.Collections.ObjectModel; 8 using System.ComponentModel; 9 using System.Diagnostics.CodeAnalysis; 10 using System.Linq; 11 using System.Runtime.CompilerServices; 12 using System.Runtime.InteropServices; 13 using System.Threading; 14 using System.Threading.Tasks; 15 using CommunityToolkit.Mvvm.Input; 16 using ManagedCommon; 17 using Microsoft.PowerToys.Settings.UI.Library; 18 using Microsoft.UI; 19 using Microsoft.UI.Dispatching; 20 using Microsoft.UI.Windowing; 21 using PowerDisplay.Common.Drivers; 22 using PowerDisplay.Common.Drivers.DDC; 23 using PowerDisplay.Common.Models; 24 using PowerDisplay.Common.Services; 25 using PowerDisplay.Helpers; 26 using PowerDisplay.PowerDisplayXAML; 27 28 namespace PowerDisplay.ViewModels; 29 30 /// <summary> 31 /// Main ViewModel for the PowerDisplay application. 32 /// Split into partial classes for better maintainability: 33 /// - MainViewModel.cs: Core properties, construction, and disposal 34 /// - MainViewModel.Monitors.cs: Monitor discovery and management 35 /// - MainViewModel.Settings.cs: Settings UI synchronization and profiles 36 /// </summary> 37 [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicMethods)] 38 public partial class MainViewModel : INotifyPropertyChanged, IDisposable 39 { 40 [LibraryImport("user32.dll", EntryPoint = "GetMonitorInfoW", StringMarshalling = StringMarshalling.Utf16)] 41 [return: MarshalAs(UnmanagedType.Bool)] 42 private static partial bool GetMonitorInfo(IntPtr hMonitor, ref MonitorInfoEx lpmi); 43 44 private readonly MonitorManager _monitorManager; 45 private readonly DispatcherQueue _dispatcherQueue; 46 private readonly CancellationTokenSource _cancellationTokenSource; 47 private readonly SettingsUtils _settingsUtils; 48 private readonly MonitorStateManager _stateManager; 49 private readonly DisplayChangeWatcher _displayChangeWatcher; 50 51 private ObservableCollection<MonitorViewModel> _monitors; 52 private ObservableCollection<PowerDisplayProfile> _profiles; 53 private bool _isScanning; 54 private bool _isInitialized; 55 private bool _isLoading; 56 57 /// <summary> 58 /// Event triggered when UI refresh is requested due to settings changes 59 /// </summary> 60 public event EventHandler? UIRefreshRequested; 61 62 /// <summary> 63 /// Event triggered when initial monitor discovery is completed. 64 /// Used by MainWindow to know when data is ready for display. 65 /// </summary> 66 public event EventHandler? InitializationCompleted; 67 68 public MainViewModel() 69 { 70 _dispatcherQueue = DispatcherQueue.GetForCurrentThread(); 71 _cancellationTokenSource = new CancellationTokenSource(); 72 _monitors = new ObservableCollection<MonitorViewModel>(); 73 _profiles = new ObservableCollection<PowerDisplayProfile>(); 74 _isScanning = true; 75 76 // Initialize settings utils 77 _settingsUtils = SettingsUtils.Default; 78 _stateManager = new MonitorStateManager(); 79 80 // Initialize the monitor manager 81 _monitorManager = new MonitorManager(); 82 83 // Load profiles for quick apply feature 84 LoadProfiles(); 85 86 // Load UI display settings (profile switcher, identify button, color temp switcher) 87 LoadUIDisplaySettings(); 88 89 // Initialize display change watcher for auto-refresh on monitor plug/unplug 90 // Use MonitorRefreshDelay from settings to allow hardware to stabilize after plug/unplug 91 var settings = _settingsUtils.GetSettingsOrDefault<PowerDisplaySettings>(PowerDisplaySettings.ModuleName); 92 int delaySeconds = Math.Clamp(settings?.Properties?.MonitorRefreshDelay ?? 5, 1, 30); 93 _displayChangeWatcher = new DisplayChangeWatcher(_dispatcherQueue, TimeSpan.FromSeconds(delaySeconds)); 94 _displayChangeWatcher.DisplayChanged += OnDisplayChanged; 95 96 // Start initial discovery 97 _ = InitializeAsync(_cancellationTokenSource.Token); 98 } 99 100 public ObservableCollection<MonitorViewModel> Monitors 101 { 102 get => _monitors; 103 set 104 { 105 _monitors = value; 106 OnPropertyChanged(); 107 } 108 } 109 110 public ObservableCollection<PowerDisplayProfile> Profiles 111 { 112 get => _profiles; 113 set 114 { 115 _profiles = value; 116 OnPropertyChanged(); 117 OnPropertyChanged(nameof(HasProfiles)); 118 } 119 } 120 121 public bool HasProfiles => Profiles.Count > 0; 122 123 // UI display control properties - loaded from settings 124 private bool _showProfileSwitcher = true; 125 private bool _showIdentifyMonitorsButton = true; 126 127 /// <summary> 128 /// Gets a value indicating whether to show the profile switcher button. 129 /// Combines settings value with HasProfiles check. 130 /// </summary> 131 public bool ShowProfileSwitcherButton => _showProfileSwitcher && HasProfiles; 132 133 /// <summary> 134 /// Gets or sets a value indicating whether to show the profile switcher (from settings). 135 /// </summary> 136 public bool ShowProfileSwitcher 137 { 138 get => _showProfileSwitcher; 139 set 140 { 141 if (_showProfileSwitcher != value) 142 { 143 _showProfileSwitcher = value; 144 OnPropertyChanged(); 145 OnPropertyChanged(nameof(ShowProfileSwitcherButton)); 146 } 147 } 148 } 149 150 /// <summary> 151 /// Gets or sets a value indicating whether to show the identify monitors button. 152 /// </summary> 153 public bool ShowIdentifyMonitorsButton 154 { 155 get => _showIdentifyMonitorsButton; 156 set 157 { 158 if (_showIdentifyMonitorsButton != value) 159 { 160 _showIdentifyMonitorsButton = value; 161 OnPropertyChanged(); 162 } 163 } 164 } 165 166 // Custom VCP mappings - loaded from settings 167 private List<CustomVcpValueMapping> _customVcpMappings = new(); 168 169 /// <summary> 170 /// Gets or sets the custom VCP value name mappings. 171 /// These mappings override the default VCP value names for color temperature and input source. 172 /// </summary> 173 public List<CustomVcpValueMapping> CustomVcpMappings 174 { 175 get => _customVcpMappings; 176 set 177 { 178 _customVcpMappings = value ?? new List<CustomVcpValueMapping>(); 179 OnPropertyChanged(); 180 } 181 } 182 183 public bool IsScanning 184 { 185 get => _isScanning; 186 set 187 { 188 if (_isScanning != value) 189 { 190 _isScanning = value; 191 OnPropertyChanged(); 192 193 // Dependent properties that change with IsScanning 194 OnPropertyChanged(nameof(HasMonitors)); 195 OnPropertyChanged(nameof(ShowNoMonitorsMessage)); 196 OnPropertyChanged(nameof(IsInteractionEnabled)); 197 } 198 } 199 } 200 201 public bool HasMonitors => !IsScanning && Monitors.Count > 0; 202 203 public bool ShowNoMonitorsMessage => !IsScanning && Monitors.Count == 0; 204 205 public bool IsInitialized 206 { 207 get => _isInitialized; 208 private set 209 { 210 _isInitialized = value; 211 OnPropertyChanged(); 212 } 213 } 214 215 public bool IsLoading 216 { 217 get => _isLoading; 218 private set 219 { 220 _isLoading = value; 221 OnPropertyChanged(); 222 OnPropertyChanged(nameof(IsInteractionEnabled)); 223 } 224 } 225 226 /// <summary> 227 /// Gets a value indicating whether user interaction is enabled (not loading or scanning). 228 /// </summary> 229 public bool IsInteractionEnabled => !IsLoading && !IsScanning; 230 231 [RelayCommand] 232 private async Task RefreshAsync() => await RefreshMonitorsAsync(); 233 234 [RelayCommand] 235 private unsafe void IdentifyMonitors() 236 { 237 try 238 { 239 // Get all display areas (virtual desktop regions) 240 var displayAreas = DisplayArea.FindAll(); 241 242 // Get all monitor info from QueryDisplayConfig 243 var allDisplayInfo = DdcCiNative.GetAllMonitorDisplayInfo().Values.ToList(); 244 245 // Build GDI name to MonitorNumber(s) mapping 246 // Note: In mirror mode, multiple monitors may share the same GdiDeviceName 247 var gdiToMonitorNumbers = allDisplayInfo 248 .Where(info => info.MonitorNumber > 0) 249 .GroupBy(info => info.GdiDeviceName, StringComparer.OrdinalIgnoreCase) 250 .ToDictionary( 251 g => g.Key, 252 g => g.Select(info => info.MonitorNumber).Distinct().OrderBy(n => n).ToList(), 253 StringComparer.OrdinalIgnoreCase); 254 255 // For each DisplayArea, get its HMONITOR, then get GDI device name to find MonitorNumber(s) 256 int windowsCreated = 0; 257 for (int i = 0; i < displayAreas.Count; i++) 258 { 259 var displayArea = displayAreas[i]; 260 261 // Convert DisplayId to HMONITOR 262 var hMonitor = Win32Interop.GetMonitorFromDisplayId(displayArea.DisplayId); 263 if (hMonitor == IntPtr.Zero) 264 { 265 continue; 266 } 267 268 // Get GDI device name from HMONITOR 269 var monitorInfo = new MonitorInfoEx { CbSize = (uint)sizeof(MonitorInfoEx) }; 270 if (!GetMonitorInfo(hMonitor, ref monitorInfo)) 271 { 272 continue; 273 } 274 275 var gdiDeviceName = monitorInfo.GetDeviceName(); 276 277 // Look up MonitorNumber(s) by GDI device name 278 if (!gdiToMonitorNumbers.TryGetValue(gdiDeviceName, out var monitorNumbers) || monitorNumbers.Count == 0) 279 { 280 continue; 281 } 282 283 // Format display text: single number for normal mode, "1|2" for mirror mode 284 var displayText = string.Join("|", monitorNumbers); 285 286 // Create and position identify window 287 var identifyWindow = new IdentifyWindow(displayText); 288 identifyWindow.PositionOnDisplay(displayArea); 289 identifyWindow.Activate(); 290 windowsCreated++; 291 } 292 } 293 catch (Exception ex) 294 { 295 Logger.LogError($"Failed to identify monitors: {ex.Message}"); 296 } 297 } 298 299 [RelayCommand] 300 private async Task ApplyProfile(PowerDisplayProfile? profile) 301 { 302 if (profile != null && profile.IsValid()) 303 { 304 await ApplyProfileAsync(profile.MonitorSettings); 305 } 306 } 307 308 public event PropertyChangedEventHandler? PropertyChanged; 309 310 protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null) 311 { 312 PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); 313 } 314 315 public void Dispose() 316 { 317 // Cancel all async operations first 318 _cancellationTokenSource?.Cancel(); 319 320 // Dispose each resource independently to ensure all get cleaned up 321 try 322 { 323 _displayChangeWatcher?.Dispose(); 324 } 325 catch 326 { 327 } 328 329 // Dispose monitor view models 330 foreach (var vm in Monitors) 331 { 332 try 333 { 334 vm.Dispose(); 335 } 336 catch 337 { 338 } 339 } 340 341 try 342 { 343 _monitorManager?.Dispose(); 344 } 345 catch 346 { 347 } 348 349 try 350 { 351 _stateManager?.Dispose(); 352 } 353 catch 354 { 355 } 356 357 try 358 { 359 _cancellationTokenSource?.Dispose(); 360 } 361 catch 362 { 363 } 364 365 try 366 { 367 Monitors.Clear(); 368 } 369 catch 370 { 371 } 372 373 GC.SuppressFinalize(this); 374 } 375 376 /// <summary> 377 /// Load profiles from disk for quick apply feature 378 /// </summary> 379 private void LoadProfiles() 380 { 381 try 382 { 383 var profilesData = ProfileService.LoadProfiles(); 384 _profiles.Clear(); 385 foreach (var profile in profilesData.Profiles) 386 { 387 _profiles.Add(profile); 388 } 389 390 OnPropertyChanged(nameof(HasProfiles)); 391 OnPropertyChanged(nameof(ShowProfileSwitcherButton)); 392 } 393 catch (Exception ex) 394 { 395 Logger.LogError($"[Profile] Failed to load profiles: {ex.Message}"); 396 } 397 } 398 399 /// <summary> 400 /// Load UI display settings from settings file 401 /// </summary> 402 private void LoadUIDisplaySettings() 403 { 404 try 405 { 406 var settings = _settingsUtils.GetSettingsOrDefault<PowerDisplaySettings>(PowerDisplaySettings.ModuleName); 407 ShowProfileSwitcher = settings.Properties.ShowProfileSwitcher; 408 ShowIdentifyMonitorsButton = settings.Properties.ShowIdentifyMonitorsButton; 409 410 // Load custom VCP mappings (now using shared type from PowerDisplay.Common.Models) 411 CustomVcpMappings = settings.Properties.CustomVcpMappings?.ToList() ?? new List<CustomVcpValueMapping>(); 412 Logger.LogInfo($"[Settings] Loaded {CustomVcpMappings.Count} custom VCP mappings"); 413 } 414 catch (Exception ex) 415 { 416 Logger.LogError($"[Settings] Failed to load UI display settings: {ex.Message}"); 417 } 418 } 419 420 /// <summary> 421 /// Handles display configuration changes detected by the DisplayChangeWatcher. 422 /// The DisplayChangeWatcher already applies the configured delay (MonitorRefreshDelay) 423 /// to allow hardware to stabilize, so we can refresh immediately here. 424 /// </summary> 425 private async void OnDisplayChanged(object? sender, EventArgs e) 426 { 427 // Set scanning state to provide visual feedback 428 IsScanning = true; 429 430 // Perform refresh - DisplayChangeWatcher has already waited for hardware to stabilize 431 await RefreshMonitorsAsync(skipScanningCheck: true); 432 } 433 434 /// <summary> 435 /// Starts watching for display changes. Call after initialization is complete. 436 /// </summary> 437 public void StartDisplayWatching() 438 { 439 _displayChangeWatcher.Start(); 440 } 441 442 /// <summary> 443 /// Stops watching for display changes. 444 /// </summary> 445 public void StopDisplayWatching() 446 { 447 _displayChangeWatcher.Stop(); 448 } 449 }