/ src / settings-ui / Settings.UI / ViewModels / PowerDisplayViewModel.cs
PowerDisplayViewModel.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.Globalization;
  9  using System.Linq;
 10  using System.Runtime.CompilerServices;
 11  using System.Text.Json;
 12  
 13  using global::PowerToys.GPOWrapper;
 14  using ManagedCommon;
 15  using Microsoft.PowerToys.Settings.UI.Helpers;
 16  using Microsoft.PowerToys.Settings.UI.Library;
 17  using Microsoft.PowerToys.Settings.UI.Library.Helpers;
 18  using Microsoft.PowerToys.Settings.UI.Library.Interfaces;
 19  using Microsoft.PowerToys.Settings.UI.Library.ViewModels.Commands;
 20  using PowerDisplay.Common.Models;
 21  using PowerDisplay.Common.Services;
 22  using PowerDisplay.Common.Utils;
 23  using PowerToys.Interop;
 24  
 25  namespace Microsoft.PowerToys.Settings.UI.ViewModels
 26  {
 27      public partial class PowerDisplayViewModel : PageViewModelBase
 28      {
 29          protected override string ModuleName => PowerDisplaySettings.ModuleName;
 30  
 31          private GeneralSettings GeneralSettingsConfig { get; set; }
 32  
 33          private SettingsUtils SettingsUtils { get; set; }
 34  
 35          public ButtonClickCommand LaunchEventHandler => new ButtonClickCommand(Launch);
 36  
 37          public PowerDisplayViewModel(SettingsUtils settingsUtils, ISettingsRepository<GeneralSettings> settingsRepository, ISettingsRepository<PowerDisplaySettings> powerDisplaySettingsRepository, Func<string, int> ipcMSGCallBackFunc)
 38          {
 39              // Set up localized VCP code names for UI display
 40              VcpNames.LocalizedCodeNameProvider = GetLocalizedVcpCodeName;
 41  
 42              // To obtain the general settings configurations of PowerToys Settings.
 43              ArgumentNullException.ThrowIfNull(settingsRepository);
 44  
 45              SettingsUtils = settingsUtils;
 46              GeneralSettingsConfig = settingsRepository.SettingsConfig;
 47  
 48              _settings = powerDisplaySettingsRepository.SettingsConfig;
 49  
 50              InitializeEnabledValue();
 51  
 52              // Initialize monitors collection using property setter for proper subscription setup
 53              var loadedMonitors = _settings.Properties.Monitors;
 54  
 55              Logger.LogInfo($"[Constructor] Initializing with {loadedMonitors.Count} monitors from settings");
 56  
 57              Monitors = new ObservableCollection<MonitorInfo>(loadedMonitors);
 58  
 59              // set the callback functions value to handle outgoing IPC message.
 60              SendConfigMSG = ipcMSGCallBackFunc;
 61  
 62              // Subscribe to collection changes for HasProfiles binding
 63              _profiles.CollectionChanged += (s, e) => OnPropertyChanged(nameof(HasProfiles));
 64  
 65              // Load profiles
 66              LoadProfiles();
 67  
 68              // Load custom VCP mappings
 69              LoadCustomVcpMappings();
 70  
 71              // Listen for monitor refresh events from PowerDisplay.exe
 72              NativeEventWaiter.WaitForEventLoop(
 73                  Constants.RefreshPowerDisplayMonitorsEvent(),
 74                  () =>
 75                  {
 76                      Logger.LogInfo("Received refresh monitors event from PowerDisplay.exe");
 77                      ReloadMonitorsFromSettings();
 78                  });
 79          }
 80  
 81          private GpoRuleConfigured _enabledGpoRuleConfiguration;
 82          private bool _enabledStateIsGPOConfigured;
 83  
 84          private void InitializeEnabledValue()
 85          {
 86              _enabledGpoRuleConfiguration = GPOWrapper.GetConfiguredPowerDisplayEnabledValue();
 87              if (_enabledGpoRuleConfiguration == GpoRuleConfigured.Disabled || _enabledGpoRuleConfiguration == GpoRuleConfigured.Enabled)
 88              {
 89                  // Get the enabled state from GPO
 90                  _enabledStateIsGPOConfigured = true;
 91                  _isEnabled = _enabledGpoRuleConfiguration == GpoRuleConfigured.Enabled;
 92              }
 93              else
 94              {
 95                  _isEnabled = GeneralSettingsConfig.Enabled.PowerDisplay;
 96              }
 97          }
 98  
 99          public bool IsEnabled
100          {
101              get => _isEnabled;
102              set
103              {
104                  if (_enabledStateIsGPOConfigured)
105                  {
106                      // If it's GPO configured, shouldn't be able to change this state.
107                      return;
108                  }
109  
110                  if (_isEnabled != value)
111                  {
112                      _isEnabled = value;
113                      OnPropertyChanged(nameof(IsEnabled));
114  
115                      GeneralSettingsConfig.Enabled.PowerDisplay = value;
116                      OutGoingGeneralSettings outgoing = new OutGoingGeneralSettings(GeneralSettingsConfig);
117                      SendConfigMSG(outgoing.ToString());
118                  }
119              }
120          }
121  
122          public bool IsEnabledGpoConfigured
123          {
124              get => _enabledStateIsGPOConfigured;
125          }
126  
127          public bool RestoreSettingsOnStartup
128          {
129              get => _settings.Properties.RestoreSettingsOnStartup;
130              set => SetSettingsProperty(_settings.Properties.RestoreSettingsOnStartup, value, v => _settings.Properties.RestoreSettingsOnStartup = v);
131          }
132  
133          public bool ShowSystemTrayIcon
134          {
135              get => _settings.Properties.ShowSystemTrayIcon;
136              set
137              {
138                  if (SetSettingsProperty(_settings.Properties.ShowSystemTrayIcon, value, v => _settings.Properties.ShowSystemTrayIcon = v))
139                  {
140                      // Explicitly signal PowerDisplay to refresh tray icon
141                      // This is needed because set_config() doesn't signal SettingsUpdatedEvent to avoid UI refresh issues
142                      SignalSettingsUpdated();
143                      Logger.LogInfo($"ShowSystemTrayIcon changed to {value}");
144                  }
145              }
146          }
147  
148          public bool ShowProfileSwitcher
149          {
150              get => _settings.Properties.ShowProfileSwitcher;
151              set
152              {
153                  if (SetSettingsProperty(_settings.Properties.ShowProfileSwitcher, value, v => _settings.Properties.ShowProfileSwitcher = v))
154                  {
155                      SignalSettingsUpdated();
156                      Logger.LogInfo($"ShowProfileSwitcher changed to {value}");
157                  }
158              }
159          }
160  
161          public bool ShowIdentifyMonitorsButton
162          {
163              get => _settings.Properties.ShowIdentifyMonitorsButton;
164              set
165              {
166                  if (SetSettingsProperty(_settings.Properties.ShowIdentifyMonitorsButton, value, v => _settings.Properties.ShowIdentifyMonitorsButton = v))
167                  {
168                      SignalSettingsUpdated();
169                      Logger.LogInfo($"ShowIdentifyMonitorsButton changed to {value}");
170                  }
171              }
172          }
173  
174          public HotkeySettings ActivationShortcut
175          {
176              get => _settings.Properties.ActivationShortcut;
177              set
178              {
179                  if (SetSettingsProperty(_settings.Properties.ActivationShortcut, value, v => _settings.Properties.ActivationShortcut = v))
180                  {
181                      // Signal PowerDisplay.exe to re-register the hotkey
182                      EventHelper.SignalEvent(Constants.HotkeyUpdatedPowerDisplayEvent());
183                      Logger.LogInfo($"ActivationShortcut changed, signaled HotkeyUpdatedPowerDisplayEvent");
184                  }
185              }
186          }
187  
188          public override Dictionary<string, HotkeySettings[]> GetAllHotkeySettings()
189          {
190              var hotkeysDict = new Dictionary<string, HotkeySettings[]>
191              {
192                  [ModuleName] = [ActivationShortcut],
193              };
194  
195              return hotkeysDict;
196          }
197  
198          /// <summary>
199          /// Gets or sets the delay in seconds before refreshing monitors after display changes.
200          /// </summary>
201          public int MonitorRefreshDelay
202          {
203              get => _settings.Properties.MonitorRefreshDelay;
204              set => SetSettingsProperty(_settings.Properties.MonitorRefreshDelay, value, v => _settings.Properties.MonitorRefreshDelay = v);
205          }
206  
207          private readonly List<int> _monitorRefreshDelayOptions = new List<int> { 1, 2, 3, 5, 10 };
208  
209          public List<int> MonitorRefreshDelayOptions => _monitorRefreshDelayOptions;
210  
211          public ObservableCollection<MonitorInfo> Monitors
212          {
213              get => _monitors;
214              set
215              {
216                  if (_monitors != null)
217                  {
218                      _monitors.CollectionChanged -= Monitors_CollectionChanged;
219                      UnsubscribeFromItemPropertyChanged(_monitors);
220                  }
221  
222                  _monitors = value;
223  
224                  if (_monitors != null)
225                  {
226                      _monitors.CollectionChanged += Monitors_CollectionChanged;
227                      SubscribeToItemPropertyChanged(_monitors);
228                  }
229  
230                  OnPropertyChanged(nameof(Monitors));
231                  HasMonitors = _monitors?.Count > 0;
232  
233                  // Update TotalMonitorCount for dynamic DisplayName
234                  UpdateTotalMonitorCount();
235              }
236          }
237  
238          public bool HasMonitors
239          {
240              get => _hasMonitors;
241              set
242              {
243                  if (_hasMonitors != value)
244                  {
245                      _hasMonitors = value;
246                      OnPropertyChanged();
247                  }
248              }
249          }
250  
251          private void Monitors_CollectionChanged(object sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
252          {
253              SubscribeToItemPropertyChanged(e.NewItems?.Cast<MonitorInfo>());
254              UnsubscribeFromItemPropertyChanged(e.OldItems?.Cast<MonitorInfo>());
255  
256              HasMonitors = _monitors.Count > 0;
257              _settings.Properties.Monitors = _monitors.ToList();
258              NotifySettingsChanged();
259  
260              // Update TotalMonitorCount for dynamic DisplayName
261              UpdateTotalMonitorCount();
262          }
263  
264          /// <summary>
265          /// Update TotalMonitorCount on all monitors for dynamic DisplayName formatting.
266          /// When multiple monitors exist, DisplayName shows "Name N" format.
267          /// </summary>
268          private void UpdateTotalMonitorCount()
269          {
270              if (_monitors == null)
271              {
272                  return;
273              }
274  
275              var count = _monitors.Count;
276              foreach (var monitor in _monitors)
277              {
278                  monitor.TotalMonitorCount = count;
279              }
280          }
281  
282          [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA1816:Dispose methods should call SuppressFinalize", Justification = "Base class PageViewModelBase.Dispose() handles GC.SuppressFinalize")]
283          public override void Dispose()
284          {
285              // Unsubscribe from monitor property changes
286              UnsubscribeFromItemPropertyChanged(_monitors);
287  
288              // Unsubscribe from collection changes
289              if (_monitors != null)
290              {
291                  _monitors.CollectionChanged -= Monitors_CollectionChanged;
292              }
293  
294              base.Dispose();
295          }
296  
297          /// <summary>
298          /// Subscribe to PropertyChanged events for items in the collection
299          /// </summary>
300          private void SubscribeToItemPropertyChanged(IEnumerable<MonitorInfo> items)
301          {
302              if (items != null)
303              {
304                  foreach (var item in items)
305                  {
306                      item.PropertyChanged += OnMonitorPropertyChanged;
307                  }
308              }
309          }
310  
311          /// <summary>
312          /// Unsubscribe from PropertyChanged events for items in the collection
313          /// </summary>
314          private void UnsubscribeFromItemPropertyChanged(IEnumerable<MonitorInfo> items)
315          {
316              if (items != null)
317              {
318                  foreach (var item in items)
319                  {
320                      item.PropertyChanged -= OnMonitorPropertyChanged;
321                  }
322              }
323          }
324  
325          /// <summary>
326          /// Handle PropertyChanged events from MonitorInfo objects
327          /// </summary>
328          private void OnMonitorPropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e)
329          {
330              if (sender is MonitorInfo monitor)
331              {
332                  Logger.LogDebug($"[PowerDisplayViewModel] Monitor {monitor.Name} property {e.PropertyName} changed");
333              }
334  
335              // Update the settings object to keep it in sync
336              _settings.Properties.Monitors = _monitors.ToList();
337  
338              // Save settings when any monitor property changes
339              NotifySettingsChanged();
340  
341              // For feature visibility properties, explicitly signal PowerDisplay to refresh
342              // This is needed because set_config() doesn't signal SettingsUpdatedEvent to avoid UI refresh issues
343              if (e.PropertyName == nameof(MonitorInfo.EnableContrast) ||
344                  e.PropertyName == nameof(MonitorInfo.EnableVolume) ||
345                  e.PropertyName == nameof(MonitorInfo.EnableInputSource) ||
346                  e.PropertyName == nameof(MonitorInfo.EnableRotation) ||
347                  e.PropertyName == nameof(MonitorInfo.EnableColorTemperature) ||
348                  e.PropertyName == nameof(MonitorInfo.EnablePowerState) ||
349                  e.PropertyName == nameof(MonitorInfo.IsHidden))
350              {
351                  SignalSettingsUpdated();
352              }
353          }
354  
355          /// <summary>
356          /// Signal PowerDisplay.exe that settings have been updated and need to be applied
357          /// </summary>
358          private void SignalSettingsUpdated()
359          {
360              EventHelper.SignalEvent(Constants.SettingsUpdatedPowerDisplayEvent());
361              Logger.LogInfo("Signaled SettingsUpdatedPowerDisplayEvent for feature visibility change");
362          }
363  
364          public void Launch()
365          {
366              var actionMessage = new PowerDisplayActionMessage
367              {
368                  Action = new PowerDisplayActionMessage.ActionData
369                  {
370                      PowerDisplay = new PowerDisplayActionMessage.PowerDisplayAction
371                      {
372                          ActionName = "Launch",
373                          Value = string.Empty,
374                      },
375                  },
376              };
377  
378              SendConfigMSG(JsonSerializer.Serialize(actionMessage, SettingsSerializationContext.Default.PowerDisplayActionMessage));
379          }
380  
381          /// <summary>
382          /// Reload monitor list from settings file (called when PowerDisplay.exe signals monitor changes)
383          /// </summary>
384          private void ReloadMonitorsFromSettings()
385          {
386              try
387              {
388                  Logger.LogInfo("Reloading monitors from settings file");
389  
390                  // Read fresh settings from file
391                  var updatedSettings = SettingsUtils.GetSettingsOrDefault<PowerDisplaySettings>(PowerDisplaySettings.ModuleName);
392                  var updatedMonitors = updatedSettings.Properties.Monitors;
393  
394                  Logger.LogInfo($"[ReloadMonitors] Loaded {updatedMonitors.Count} monitors from settings");
395  
396                  // Update existing MonitorInfo objects instead of replacing the collection
397                  // This preserves XAML x:Bind bindings which reference specific object instances
398                  if (Monitors == null)
399                  {
400                      // First time initialization - create new collection
401                      Monitors = new ObservableCollection<MonitorInfo>(updatedMonitors);
402                  }
403                  else
404                  {
405                      // Create a dictionary for quick lookup by Id
406                      var updatedMonitorsDict = updatedMonitors.ToDictionary(m => m.Id, m => m);
407  
408                      // Update existing monitors or remove ones that no longer exist
409                      for (int i = Monitors.Count - 1; i >= 0; i--)
410                      {
411                          var existingMonitor = Monitors[i];
412                          if (updatedMonitorsDict.TryGetValue(existingMonitor.Id, out var updatedMonitor)
413                              && updatedMonitor != null)
414                          {
415                              // Monitor still exists - update its properties in place
416                              Logger.LogInfo($"[ReloadMonitors] Updating existing monitor: {existingMonitor.Id}");
417                              existingMonitor.UpdateFrom(updatedMonitor);
418  
419                              updatedMonitorsDict.Remove(existingMonitor.Id);
420                          }
421                          else
422                          {
423                              // Monitor no longer exists - remove from collection
424                              Logger.LogInfo($"[ReloadMonitors] Removing monitor: {existingMonitor.Id}");
425                              Monitors.RemoveAt(i);
426                          }
427                      }
428  
429                      // Add any new monitors that weren't in the existing collection
430                      foreach (var newMonitor in updatedMonitorsDict.Values)
431                      {
432                          Logger.LogInfo($"[ReloadMonitors] Adding new monitor: {newMonitor.Id}");
433                          Monitors.Add(newMonitor);
434                      }
435                  }
436  
437                  // Update internal settings reference
438                  _settings.Properties.Monitors = updatedMonitors;
439  
440                  Logger.LogInfo($"Successfully reloaded {updatedMonitors.Count} monitors");
441              }
442              catch (Exception ex)
443              {
444                  Logger.LogError($"Failed to reload monitors from settings: {ex.Message}");
445              }
446          }
447  
448          private Func<string, int> SendConfigMSG { get; }
449  
450          private bool _isEnabled;
451          private PowerDisplaySettings _settings;
452          private ObservableCollection<MonitorInfo> _monitors;
453          private bool _hasMonitors;
454  
455          // Profile-related fields
456          private ObservableCollection<PowerDisplayProfile> _profiles = new ObservableCollection<PowerDisplayProfile>();
457  
458          // Custom VCP mapping fields
459          private ObservableCollection<CustomVcpValueMapping> _customVcpMappings;
460  
461          /// <summary>
462          /// Gets collection of custom VCP value name mappings
463          /// </summary>
464          public ObservableCollection<CustomVcpValueMapping> CustomVcpMappings => _customVcpMappings;
465  
466          /// <summary>
467          /// Gets whether there are any custom VCP mappings (for UI binding)
468          /// </summary>
469          public bool HasCustomVcpMappings => _customVcpMappings?.Count > 0;
470  
471          /// <summary>
472          /// Gets collection of available profiles (for button display)
473          /// </summary>
474          public ObservableCollection<PowerDisplayProfile> Profiles => _profiles;
475  
476          /// <summary>
477          /// Gets whether there are any profiles (for UI binding)
478          /// </summary>
479          public bool HasProfiles => _profiles?.Count > 0;
480  
481          public void RefreshEnabledState()
482          {
483              InitializeEnabledValue();
484              OnPropertyChanged(nameof(IsEnabled));
485          }
486  
487          private bool SetSettingsProperty<T>(T currentValue, T newValue, Action<T> setter, [CallerMemberName] string propertyName = null)
488          {
489              if (EqualityComparer<T>.Default.Equals(currentValue, newValue))
490              {
491                  return false;
492              }
493  
494              setter(newValue);
495              OnPropertyChanged(propertyName);
496              NotifySettingsChanged();
497              return true;
498          }
499  
500          /// <summary>
501          /// Load profiles from disk
502          /// </summary>
503          private void LoadProfiles()
504          {
505              try
506              {
507                  var profilesData = ProfileService.LoadProfiles();
508  
509                  // Load profile objects (no Custom - it's not a profile anymore)
510                  Profiles.Clear();
511                  foreach (var profile in profilesData.Profiles)
512                  {
513                      Profiles.Add(profile);
514                  }
515  
516                  Logger.LogInfo($"Loaded {Profiles.Count} profiles");
517              }
518              catch (Exception ex)
519              {
520                  Logger.LogError($"Failed to load profiles: {ex.Message}");
521                  Profiles.Clear();
522              }
523          }
524  
525          /// <summary>
526          /// Apply a profile to monitors
527          /// </summary>
528          public void ApplyProfile(PowerDisplayProfile profile)
529          {
530              try
531              {
532                  if (profile == null || !profile.IsValid())
533                  {
534                      Logger.LogWarning("Invalid profile");
535                      return;
536                  }
537  
538                  Logger.LogInfo($"Applying profile: {profile.Name}");
539  
540                  // Send custom action to trigger profile application
541                  // The profile name is passed via Named Pipe IPC to PowerDisplay.exe
542                  var actionMessage = new PowerDisplayActionMessage
543                  {
544                      Action = new PowerDisplayActionMessage.ActionData
545                      {
546                          PowerDisplay = new PowerDisplayActionMessage.PowerDisplayAction
547                          {
548                              ActionName = "ApplyProfile",
549                              Value = profile.Name,
550                          },
551                      },
552                  };
553  
554                  SendConfigMSG(JsonSerializer.Serialize(actionMessage, SettingsSerializationContext.Default.PowerDisplayActionMessage));
555  
556                  Logger.LogInfo($"Profile '{profile.Name}' applied successfully");
557              }
558              catch (Exception ex)
559              {
560                  Logger.LogError($"Failed to apply profile: {ex.Message}");
561              }
562          }
563  
564          /// <summary>
565          /// Create a new profile
566          /// </summary>
567          public void CreateProfile(PowerDisplayProfile profile)
568          {
569              try
570              {
571                  if (profile == null || !profile.IsValid())
572                  {
573                      Logger.LogWarning("Invalid profile");
574                      return;
575                  }
576  
577                  Logger.LogInfo($"Creating profile: {profile.Name}");
578  
579                  var profilesData = ProfileService.LoadProfiles();
580                  profilesData.SetProfile(profile);
581                  ProfileService.SaveProfiles(profilesData);
582  
583                  // Reload profile list
584                  LoadProfiles();
585  
586                  // Signal PowerDisplay to reload profiles
587                  SignalSettingsUpdated();
588  
589                  Logger.LogInfo($"Profile '{profile.Name}' created successfully");
590              }
591              catch (Exception ex)
592              {
593                  Logger.LogError($"Failed to create profile: {ex.Message}");
594              }
595          }
596  
597          /// <summary>
598          /// Update an existing profile
599          /// </summary>
600          public void UpdateProfile(string oldName, PowerDisplayProfile newProfile)
601          {
602              try
603              {
604                  if (newProfile == null || !newProfile.IsValid())
605                  {
606                      Logger.LogWarning("Invalid profile");
607                      return;
608                  }
609  
610                  Logger.LogInfo($"Updating profile: {oldName} -> {newProfile.Name}");
611  
612                  var profilesData = ProfileService.LoadProfiles();
613  
614                  // Remove old profile and add updated one
615                  profilesData.RemoveProfile(oldName);
616                  profilesData.SetProfile(newProfile);
617                  ProfileService.SaveProfiles(profilesData);
618  
619                  // Reload profile list
620                  LoadProfiles();
621  
622                  // Signal PowerDisplay to reload profiles
623                  SignalSettingsUpdated();
624  
625                  Logger.LogInfo($"Profile updated to '{newProfile.Name}' successfully");
626              }
627              catch (Exception ex)
628              {
629                  Logger.LogError($"Failed to update profile: {ex.Message}");
630              }
631          }
632  
633          /// <summary>
634          /// Delete a profile
635          /// </summary>
636          public void DeleteProfile(string profileName)
637          {
638              try
639              {
640                  if (string.IsNullOrEmpty(profileName))
641                  {
642                      return;
643                  }
644  
645                  Logger.LogInfo($"Deleting profile: {profileName}");
646  
647                  var profilesData = ProfileService.LoadProfiles();
648                  profilesData.RemoveProfile(profileName);
649                  ProfileService.SaveProfiles(profilesData);
650  
651                  // Reload profile list
652                  LoadProfiles();
653  
654                  // Signal PowerDisplay to reload profiles
655                  SignalSettingsUpdated();
656  
657                  Logger.LogInfo($"Profile '{profileName}' deleted successfully");
658              }
659              catch (Exception ex)
660              {
661                  Logger.LogError($"Failed to delete profile: {ex.Message}");
662              }
663          }
664  
665          /// <summary>
666          /// Load custom VCP mappings from settings
667          /// </summary>
668          private void LoadCustomVcpMappings()
669          {
670              List<CustomVcpValueMapping> mappings;
671              try
672              {
673                  mappings = _settings.Properties.CustomVcpMappings ?? new List<CustomVcpValueMapping>();
674                  Logger.LogInfo($"Loaded {mappings.Count} custom VCP mappings");
675              }
676              catch (Exception ex)
677              {
678                  Logger.LogError($"Failed to load custom VCP mappings: {ex.Message}");
679                  mappings = new List<CustomVcpValueMapping>();
680              }
681  
682              _customVcpMappings = new ObservableCollection<CustomVcpValueMapping>(mappings);
683              _customVcpMappings.CollectionChanged += (s, e) => OnPropertyChanged(nameof(HasCustomVcpMappings));
684              OnPropertyChanged(nameof(CustomVcpMappings));
685              OnPropertyChanged(nameof(HasCustomVcpMappings));
686          }
687  
688          /// <summary>
689          /// Add a new custom VCP mapping.
690          /// No duplicate checking - mappings are resolved by order (first match wins in VcpNames).
691          /// </summary>
692          public void AddCustomVcpMapping(CustomVcpValueMapping mapping)
693          {
694              if (mapping == null)
695              {
696                  return;
697              }
698  
699              CustomVcpMappings.Add(mapping);
700              Logger.LogInfo($"Added custom VCP mapping: VCP=0x{mapping.VcpCode:X2}, Value=0x{mapping.Value:X2} -> {mapping.CustomName}");
701              SaveCustomVcpMappings();
702          }
703  
704          /// <summary>
705          /// Update an existing custom VCP mapping
706          /// </summary>
707          public void UpdateCustomVcpMapping(CustomVcpValueMapping oldMapping, CustomVcpValueMapping newMapping)
708          {
709              if (oldMapping == null || newMapping == null)
710              {
711                  return;
712              }
713  
714              var index = CustomVcpMappings.IndexOf(oldMapping);
715              if (index >= 0)
716              {
717                  CustomVcpMappings[index] = newMapping;
718                  Logger.LogInfo($"Updated custom VCP mapping at index {index}");
719                  SaveCustomVcpMappings();
720              }
721          }
722  
723          /// <summary>
724          /// Delete a custom VCP mapping
725          /// </summary>
726          public void DeleteCustomVcpMapping(CustomVcpValueMapping mapping)
727          {
728              if (mapping == null)
729              {
730                  return;
731              }
732  
733              if (CustomVcpMappings.Remove(mapping))
734              {
735                  Logger.LogInfo($"Deleted custom VCP mapping: VCP=0x{mapping.VcpCode:X2}, Value=0x{mapping.Value:X2}");
736                  SaveCustomVcpMappings();
737              }
738          }
739  
740          /// <summary>
741          /// Save custom VCP mappings to settings
742          /// </summary>
743          private void SaveCustomVcpMappings()
744          {
745              _settings.Properties.CustomVcpMappings = CustomVcpMappings.ToList();
746              NotifySettingsChanged();
747  
748              // Signal PowerDisplay to reload settings
749              SignalSettingsUpdated();
750          }
751  
752          /// <summary>
753          /// Provides localized VCP code names for UI display.
754          /// Looks for resource string with pattern "PowerDisplay_VcpCode_Name_0xXX".
755          /// Returns null for unknown codes to use the default MCCS name.
756          /// </summary>
757  #nullable enable
758          private static string? GetLocalizedVcpCodeName(byte vcpCode)
759          {
760              var resourceKey = $"PowerDisplay_VcpCode_Name_0x{vcpCode:X2}";
761              var localizedName = ResourceLoaderInstance.ResourceLoader.GetString(resourceKey);
762  
763              // ResourceLoader returns empty string if key not found
764              return string.IsNullOrEmpty(localizedName) ? null : localizedName;
765          }
766  #nullable restore
767  
768          private void NotifySettingsChanged()
769          {
770              // Skip during initialization when SendConfigMSG is not yet set
771              if (SendConfigMSG == null)
772              {
773                  return;
774              }
775  
776              // Persist locally first so settings survive even if the module DLL isn't loaded yet.
777              SettingsUtils.SaveSettings(_settings.ToJsonString(), PowerDisplaySettings.ModuleName);
778  
779              // Using InvariantCulture as this is an IPC message
780              // This message will be intercepted by the runner, which passes the serialized JSON to
781              // PowerDisplay Module Interface's set_config() method, which then applies it in-process.
782              SendConfigMSG(
783                     string.Format(
784                         CultureInfo.InvariantCulture,
785                         "{{ \"powertoys\": {{ \"{0}\": {1} }} }}",
786                         PowerDisplaySettings.ModuleName,
787                         _settings.ToJsonString()));
788          }
789      }
790  }