/ src / modules / powerdisplay / PowerDisplay / ViewModels / MainViewModel.cs
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  }