/
githubmirror
/
PowerToys
Обзор
Документация
Войти
/
githubmirror
/
PowerToys
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
src/settings-ui/Settings.UI/ViewModels/PowerDisplayViewModel.cs
1 149 строк
44 KB
moooyo
PowerDisplay: Adjust brightness by scrolling over the tray icon (#49446)
31 июл 2026, 11:21
Не верифицирован
31 июл 2026, 11:21
8f63402
Код
Авторство
О чём код?
// 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.Collections.Generic; using System.Collections.ObjectModel; using System.Globalization; using System.IO; using System.Linq; using System.Runtime.CompilerServices; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using global::PowerToys.GPOWrapper; using ManagedCommon; using Microsoft.PowerToys.Settings.UI.Helpers; using Microsoft.PowerToys.Settings.UI.Library; using Microsoft.PowerToys.Settings.UI.Library.Helpers; using Microsoft.PowerToys.Settings.UI.Library.Interfaces; using Microsoft.PowerToys.Settings.UI.Library.ViewModels.Commands; using PowerDisplay.Models; using PowerToys.Interop; namespace Microsoft.PowerToys.Settings.UI.ViewModels { public partial class PowerDisplayViewModel : PageViewModelBase { // Mirror of PowerDisplay.Lib's PathConstants.CrashDetectedFlagPath. Settings UI cannot // reference PowerDisplay.Lib, so the path is recomputed here. Keep in sync with that file. private static readonly string CrashDetectedFlagPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Microsoft", "PowerToys", "PowerDisplay", "crash_detected.flag"); private bool _isProfilesLoading; protected override string ModuleName => PowerDisplaySettings.ModuleName; private GeneralSettings GeneralSettingsConfig { get; set; } private SettingsUtils SettingsUtils { get; set; } public ButtonClickCommand LaunchEventHandler => new ButtonClickCommand(Launch); public PowerDisplayViewModel(SettingsUtils settingsUtils, ISettingsRepository<GeneralSettings> settingsRepository, ISettingsRepository<PowerDisplaySettings> powerDisplaySettingsRepository, Func<string, int> ipcMSGCallBackFunc) : this( settingsUtils, settingsRepository, powerDisplaySettingsRepository, ipcMSGCallBackFunc, NativeEventWaiter.WaitForEventLoop) { } public PowerDisplayViewModel(SettingsUtils settingsUtils, ISettingsRepository<GeneralSettings> settingsRepository, ISettingsRepository<PowerDisplaySettings> powerDisplaySettingsRepository, Func<string, int> ipcMSGCallBackFunc, Action<string, Action> waitForEventLoop) { // To obtain the general settings configurations of PowerToys Settings. ArgumentNullException.ThrowIfNull(settingsRepository); ArgumentNullException.ThrowIfNull(waitForEventLoop); SettingsUtils = settingsUtils; GeneralSettingsConfig = settingsRepository.SettingsConfig; _settings = powerDisplaySettingsRepository.SettingsConfig; InitializeEnabledValue(); // Initialize monitors collection using property setter for proper subscription setup. // Hide legacy-format Ids; the current discovery pipeline only emits "\\?\DISPLAY#..." // DevicePath Ids, so any "DDC_*" / "WMI_*" entries in settings.json are upgrade // duplicates of a "\\?\" entry kept by the rebuilder's retention rule. var loadedMonitors = FilterLegacyIds(_settings.Properties.Monitors).ToList(); Logger.LogInfo($"[Constructor] Initializing with {loadedMonitors.Count} monitors from settings (filtered)"); Monitors = new ObservableCollection<MonitorInfo>(loadedMonitors); // set the callback functions value to handle outgoing IPC message. SendConfigMSG = ipcMSGCallBackFunc; _profiles.CollectionChanged += Profiles_CollectionChanged; // Load custom VCP mappings LoadCustomVcpMappings(); // Listen for monitor refresh events from PowerDisplay.exe waitForEventLoop( Constants.RefreshPowerDisplayMonitorsEvent(), () => { Logger.LogInfo("Received refresh monitors event from PowerDisplay.exe"); ReloadMonitorsFromSettings(); }); // Crash quarantine state. The flag file is the single source of truth; the page // re-checks it on construction (catches crashes that happened before Settings UI // launched) and on every navigation via OnPageLoaded (catches crashes while // Settings UI is already open). The AutoDisable event is left as a single-consumer // signal for the runner DLL — Settings UI does not race for it. RefreshCrashLockState(); } public override void OnPageLoaded() { base.OnPageLoaded(); RefreshCrashLockState(); } private void RefreshCrashLockState() { if (File.Exists(CrashDetectedFlagPath) && !IsCrashLockActive) { Logger.LogInfo("PowerDisplayViewModel: crash flag present, locking page"); IsCrashLockActive = true; } } private GpoRuleConfigured _enabledGpoRuleConfiguration; private bool _enabledStateIsGPOConfigured; private void InitializeEnabledValue() { _enabledGpoRuleConfiguration = GPOWrapper.GetConfiguredPowerDisplayEnabledValue(); if (_enabledGpoRuleConfiguration == GpoRuleConfigured.Disabled || _enabledGpoRuleConfiguration == GpoRuleConfigured.Enabled) { // Get the enabled state from GPO _enabledStateIsGPOConfigured = true; _isEnabled = _enabledGpoRuleConfiguration == GpoRuleConfigured.Enabled; } else { _isEnabled = GeneralSettingsConfig.Enabled.PowerDisplay; } } public bool IsEnabled { get => _isEnabled; set { if (_enabledStateIsGPOConfigured) { // If it's GPO configured, shouldn't be able to change this state. return; } if (_isEnabled == value) { return; } if (value) { // Enabling PowerDisplay can crash some monitors via DDC/CI capability // fetch (see #47556 / PR #47734). Don't commit yet — confirm with the user // first, then either commit or revert the toggle via OnPropertyChanged. _ = ConfirmAndEnableModuleAsync(); } else { CommitIsEnabled(false); } } } private async Task ConfirmAndEnableModuleAsync() { try { if (await ConfirmDangerousFeatureAsync(PowerDisplayWarningKind.EnableModule)) { CommitIsEnabled(true); } } catch (Exception ex) { // ContentDialog.ShowAsync throws if another dialog is already open or the // XamlRoot has been torn down. Don't let the fire-and-forget task carry the // exception into TaskScheduler.UnobservedTaskException — log and fall through // to the finally so the ToggleSwitch revert path still runs. Logger.LogError($"PowerDisplayViewModel: enable-module confirm dialog failed: {ex.Message}"); } finally { // Either branch (commit, cancel, or exception) raises PropertyChanged so the // TwoWay binding pushes the ViewModel value back to the ToggleSwitch — commit // echoes silently, cancel/exception pulls the UI back to the original state. OnPropertyChanged(nameof(IsEnabled)); } } private void CommitIsEnabled(bool value) { _isEnabled = value; OnPropertyChanged(nameof(IsEnabled)); OnPropertyChanged(nameof(CanUseProfiles)); GeneralSettingsConfig.Enabled.PowerDisplay = value; OutGoingGeneralSettings outgoing = new OutGoingGeneralSettings(GeneralSettingsConfig); SendConfigMSG(outgoing.ToString()); } public bool IsEnabledGpoConfigured { get => _enabledStateIsGPOConfigured; } public bool IsCrashLockActive { get => _isCrashLockActive; private set { if (_isCrashLockActive != value) { _isCrashLockActive = value; OnPropertyChanged(nameof(IsCrashLockActive)); } } } public ButtonClickCommand DismissCrashWarningCommand => new ButtonClickCommand(DismissCrashWarning); private void DismissCrashWarning() { try { var path = CrashDetectedFlagPath; if (File.Exists(path)) { File.Delete(path); Logger.LogInfo("PowerDisplayViewModel: user dismissed crash warning, flag deleted"); } } catch (Exception ex) { Logger.LogError($"PowerDisplayViewModel: failed to delete crash flag: {ex.Message}"); } IsCrashLockActive = false; } public bool RestoreSettingsOnStartup { get => _settings.Properties.RestoreSettingsOnStartup; set => SetSettingsProperty(_settings.Properties.RestoreSettingsOnStartup, value, v => _settings.Properties.RestoreSettingsOnStartup = v); } /// <summary> /// View-supplied confirmation dialog. Default no-op denies all dangerous enables; /// PowerDisplayPage replaces this in its constructor with a real dialog show. /// </summary> public Func<PowerDisplayWarningKind, Task<bool>> ConfirmDangerousFeatureAsync { get; set; } = _ => Task.FromResult(false); // Dangerous toggle. TwoWay-bound to the UI; the setter handles the "ask first" // gesture entirely inside the ViewModel. Initial-binding push and post-cancel // revert both hit the equality guard at the top and no-op. public bool MaxCompatibilityMode { get => _settings.Properties.MaxCompatibilityMode; set { if (_settings.Properties.MaxCompatibilityMode == value) { return; } if (value) { // Don't commit yet. Run the async confirm, then either commit (UI is // already showing the requested state) or revert via OnPropertyChanged. _ = ConfirmAndEnableMaxCompatAsync(); } else { _settings.Properties.MaxCompatibilityMode = false; OnPropertyChanged(); NotifySettingsChanged(); SignalRescanRequest(); } } } private async Task ConfirmAndEnableMaxCompatAsync() { try { if (await ConfirmDangerousFeatureAsync(PowerDisplayWarningKind.MaxCompatibility)) { _settings.Properties.MaxCompatibilityMode = true; NotifySettingsChanged(); SignalRescanRequest(); } } catch (Exception ex) { // ContentDialog.ShowAsync throws if another dialog is already open or the // XamlRoot has been torn down. Don't let the fire-and-forget task carry the // exception into TaskScheduler.UnobservedTaskException — log and fall through // to the finally so the ToggleSwitch revert path still runs. Logger.LogError($"PowerDisplayViewModel: max-compat confirm dialog failed: {ex.Message}"); } finally { // Either branch (commit, cancel, or exception) raises PropertyChanged so the // TwoWay binding pushes the ViewModel value back to the ToggleSwitch — commit // echoes silently, cancel/exception pulls the UI back to the original state. OnPropertyChanged(nameof(MaxCompatibilityMode)); } } public bool ShowSystemTrayIcon { get => _settings.Properties.ShowSystemTrayIcon; set { if (SetSettingsProperty(_settings.Properties.ShowSystemTrayIcon, value, v => _settings.Properties.ShowSystemTrayIcon = v)) { // Explicitly signal PowerDisplay to refresh tray icon // This is needed because set_config() doesn't signal SettingsUpdatedEvent to avoid UI refresh issues SignalSettingsUpdated(); Logger.LogInfo($"ShowSystemTrayIcon changed to {value}"); } } } public bool ShowProfileSwitcher { get => _settings.Properties.ShowProfileSwitcher; set { if (SetSettingsProperty(_settings.Properties.ShowProfileSwitcher, value, v => _settings.Properties.ShowProfileSwitcher = v)) { SignalSettingsUpdated(); Logger.LogInfo($"ShowProfileSwitcher changed to {value}"); } } } public bool ShowIdentifyMonitorsButton { get => _settings.Properties.ShowIdentifyMonitorsButton; set { if (SetSettingsProperty(_settings.Properties.ShowIdentifyMonitorsButton, value, v => _settings.Properties.ShowIdentifyMonitorsButton = v)) { SignalSettingsUpdated(); Logger.LogInfo($"ShowIdentifyMonitorsButton changed to {value}"); } } } public HotkeySettings ActivationShortcut { get => _settings.Properties.ActivationShortcut; set { if (SetSettingsProperty(_settings.Properties.ActivationShortcut, value, v => _settings.Properties.ActivationShortcut = v)) { // Signal PowerDisplay.exe to re-register the hotkey SignalNamedEvent(Constants.HotkeyUpdatedPowerDisplayEvent()); Logger.LogInfo($"ActivationShortcut changed, signaled HotkeyUpdatedPowerDisplayEvent"); } } } public override Dictionary<string, HotkeySettings[]> GetAllHotkeySettings() { var hotkeysDict = new Dictionary<string, HotkeySettings[]> { [ModuleName] = [ActivationShortcut], }; return hotkeysDict; } /// <summary> /// Gets or sets the delay in seconds before refreshing monitors after display changes. /// </summary> public int MonitorRefreshDelay { get => _settings.Properties.MonitorRefreshDelay; set => SetSettingsProperty(_settings.Properties.MonitorRefreshDelay, value, v => _settings.Properties.MonitorRefreshDelay = v); } private readonly List<int> _monitorRefreshDelayOptions = new List<int> { 1, 2, 3, 5, 10 }; public List<int> MonitorRefreshDelayOptions => _monitorRefreshDelayOptions; /// <summary> /// Gets or sets the selected mouse-wheel mode as the ComboBox index. /// Enum values intentionally match the displayed item order. /// </summary> public int MouseWheelControlModeIndex { get => (int)_settings.Properties.MouseWheelControlMode.Normalize(); set { var mode = ((MouseWheelControlMode)value).Normalize(); if ((int)mode != value) { OnPropertyChanged(nameof(MouseWheelControlModeIndex)); return; } if (SetSettingsProperty( _settings.Properties.MouseWheelControlMode, mode, v => _settings.Properties.MouseWheelControlMode = v)) { SignalSettingsUpdated(); } } } /// <summary> /// Gets or sets the per-mouse-wheel-notch step shared by all PowerDisplay flyout sliders. /// </summary> public int MouseWheelIncrement { get => _settings.Properties.MouseWheelIncrement; set { if (SetSettingsProperty(_settings.Properties.MouseWheelIncrement, value, v => _settings.Properties.MouseWheelIncrement = v)) { // Push to the (possibly open) flyout so the new step takes effect immediately. SignalSettingsUpdated(); } } } private readonly List<int> _mouseWheelIncrementOptions = new List<int> { 1, 2, 5, 10, 15, 20, 25 }; public List<int> MouseWheelIncrementOptions => _mouseWheelIncrementOptions; public ObservableCollection<MonitorInfo> Monitors { get => _monitors; set { if (_monitors != null) { _monitors.CollectionChanged -= Monitors_CollectionChanged; UnsubscribeFromItemPropertyChanged(_monitors); } _monitors = value; if (_monitors != null) { _monitors.CollectionChanged += Monitors_CollectionChanged; SubscribeToItemPropertyChanged(_monitors); } OnPropertyChanged(nameof(Monitors)); HasMonitors = _monitors?.Count > 0; // Update TotalMonitorCount for dynamic DisplayName UpdateTotalMonitorCount(); } } public bool HasMonitors { get => _hasMonitors; set { if (_hasMonitors != value) { _hasMonitors = value; OnPropertyChanged(); } } } private void Monitors_CollectionChanged(object sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e) { SubscribeToItemPropertyChanged(e.NewItems?.Cast<MonitorInfo>()); UnsubscribeFromItemPropertyChanged(e.OldItems?.Cast<MonitorInfo>()); HasMonitors = _monitors.Count > 0; // Collection mutations during ReloadMonitorsFromSettings come from disk — // don't save back. ReloadMonitorsFromSettings itself rebuilds // _settings.Properties.Monitors when it's done. // Don't sync _settings.Properties.Monitors from _monitors here either — // _monitors is the filtered view and would silently strip legacy entries. if (!_isReloading) { NotifySettingsChanged(); } // Update TotalMonitorCount for dynamic DisplayName UpdateTotalMonitorCount(); } /// <summary> /// True for the DevicePath form of Monitor Id ("\\?\DISPLAY#..."). The current /// discovery pipeline only emits this form; older "DDC_*" / "WMI_*" entries in /// settings.json are upgrade-duplicates kept by the rebuilder's retention rule /// and shouldn't be bound to the UI. /// </summary> private static bool IsVisibleMonitorId(string id) => !string.IsNullOrEmpty(id) && id.StartsWith(@"\\?\", StringComparison.Ordinal); /// <summary> /// Drop legacy-format Ids. See <see cref="IsVisibleMonitorId"/>. /// </summary> private static IEnumerable<MonitorInfo> FilterLegacyIds(IEnumerable<MonitorInfo> monitors) => monitors.Where(m => IsVisibleMonitorId(m.Id)); /// <summary> /// Update TotalMonitorCount on all monitors for dynamic DisplayName formatting. /// When multiple monitors exist, DisplayName shows "Name N" format. /// </summary> private void UpdateTotalMonitorCount() { if (_monitors == null) { return; } var count = _monitors.Count; foreach (var monitor in _monitors) { monitor.TotalMonitorCount = count; } } [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA1816:Dispose methods should call SuppressFinalize", Justification = "Base class PageViewModelBase.Dispose() handles GC.SuppressFinalize")] public override void Dispose() { // Unsubscribe from monitor property changes UnsubscribeFromItemPropertyChanged(_monitors); // Unsubscribe from collection changes if (_monitors != null) { _monitors.CollectionChanged -= Monitors_CollectionChanged; } base.Dispose(); } /// <summary> /// Subscribe to PropertyChanged events for items in the collection /// </summary> private void SubscribeToItemPropertyChanged(IEnumerable<MonitorInfo> items) { if (items != null) { foreach (var item in items) { item.PropertyChanged += OnMonitorPropertyChanged; } } } /// <summary> /// Unsubscribe from PropertyChanged events for items in the collection /// </summary> private void UnsubscribeFromItemPropertyChanged(IEnumerable<MonitorInfo> items) { if (items != null) { foreach (var item in items) { item.PropertyChanged -= OnMonitorPropertyChanged; } } } /// <summary> /// Handle PropertyChanged events from MonitorInfo objects /// </summary> private void OnMonitorPropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e) { if (sender is MonitorInfo monitor) { Logger.LogDebug($"[PowerDisplayViewModel] Monitor {monitor.Name} property {e.PropertyName} changed"); } // Property changes during ReloadMonitorsFromSettings come from disk // (UpdateFrom), not user input — don't save or signal back out. if (_isReloading) { return; } // MonitorInfo is a reference type shared between _monitors and // _settings.Properties.Monitors — the property change is already visible // in the persisted list, so just trigger save. Rebuilding the list from // _monitors here would silently drop legacy entries the filter hides. NotifySettingsChanged(); // For feature visibility properties, explicitly signal PowerDisplay to refresh // This is needed because set_config() doesn't signal SettingsUpdatedEvent to avoid UI refresh issues if (e.PropertyName == nameof(MonitorInfo.EnableContrast) || e.PropertyName == nameof(MonitorInfo.EnableVolume) || e.PropertyName == nameof(MonitorInfo.EnableInputSource) || e.PropertyName == nameof(MonitorInfo.EnableRotation) || e.PropertyName == nameof(MonitorInfo.EnableColorTemperature) || e.PropertyName == nameof(MonitorInfo.EnablePowerState) || e.PropertyName == nameof(MonitorInfo.IsHidden)) { SignalSettingsUpdated(); } } /// <summary> /// Signal PowerDisplay.exe that settings have been updated and need to be applied /// </summary> private void SignalSettingsUpdated() { SignalNamedEvent(Constants.SettingsUpdatedPowerDisplayEvent()); Logger.LogInfo("Signaled SettingsUpdatedPowerDisplayEvent for feature visibility change"); } /// <summary> /// Signal PowerDisplay.exe to perform a full hardware rescan. Used when a /// setting changes that affects monitor discovery (currently: max-compatibility /// mode). Distinct from <see cref="SignalSettingsUpdated"/>, which only fires /// the lightweight settings-applied path on the module side. /// </summary> public void SignalRescanRequest() { SignalNamedEvent(Constants.RescanPowerDisplayMonitorsEvent()); Logger.LogInfo("Signaled RescanPowerDisplayMonitorsEvent (max-compat toggle finalized)"); } private static void SignalNamedEvent(string eventName) { try { using var handle = new EventWaitHandle(false, EventResetMode.AutoReset, eventName); handle.Set(); } catch (Exception ex) { Logger.LogError($"Failed to signal event '{eventName}': {ex.Message}"); } } public void Launch() { var actionMessage = new PowerDisplayActionMessage { Action = new PowerDisplayActionMessage.ActionData { PowerDisplay = new PowerDisplayActionMessage.PowerDisplayAction { ActionName = "Launch", Value = string.Empty, }, }, }; SendConfigMSG(JsonSerializer.Serialize(actionMessage, SettingsSerializationContext.Default.PowerDisplayActionMessage)); } /// <summary> /// Reload monitor list from settings file (called when PowerDisplay.exe signals monitor changes) /// </summary> private void ReloadMonitorsFromSettings() { _isReloading = true; try { Logger.LogInfo("Reloading monitors from settings file"); // Read fresh settings from file. UpdateFrom / Add / Remove below fire // PropertyChanged + CollectionChanged on the existing MonitorInfo // instances; the _isReloading guard above stops those from triggering // saves back to disk. We rebuild _settings.Properties.Monitors at the // end so visible entries keep reference identity with the items inside // _monitors (which user toggles mutate) while legacy entries take fresh // disk references (UI doesn't bind them). var updatedSettings = SettingsUtils.GetSettingsOrDefault<PowerDisplaySettings>(PowerDisplaySettings.ModuleName); var allFromDisk = updatedSettings.Properties.Monitors; var updatedMonitors = allFromDisk.Where(m => IsVisibleMonitorId(m.Id)).ToList(); var legacyFromDisk = allFromDisk.Where(m => !IsVisibleMonitorId(m.Id)).ToList(); Logger.LogInfo($"[ReloadMonitors] Loaded {updatedMonitors.Count} visible + {legacyFromDisk.Count} legacy from settings"); // Update existing MonitorInfo objects instead of replacing the collection // This preserves XAML x:Bind bindings which reference specific object instances if (Monitors == null) { // First time initialization - create new collection Monitors = new ObservableCollection<MonitorInfo>(updatedMonitors); } else { // Create a dictionary for quick lookup by Id var updatedMonitorsDict = updatedMonitors.ToDictionary(m => m.Id, m => m, MonitorIdComparer.Instance); // Update existing monitors or remove ones that no longer exist for (int i = Monitors.Count - 1; i >= 0; i--) { var existingMonitor = Monitors[i]; if (updatedMonitorsDict.TryGetValue(existingMonitor.Id, out var updatedMonitor) && updatedMonitor != null) { // Monitor still exists - update its properties in place Logger.LogInfo($"[ReloadMonitors] Updating existing monitor: {existingMonitor.Id}"); existingMonitor.UpdateFrom(updatedMonitor); updatedMonitorsDict.Remove(existingMonitor.Id); } else { // Monitor no longer exists - remove from collection Logger.LogInfo($"[ReloadMonitors] Removing monitor: {existingMonitor.Id}"); Monitors.RemoveAt(i); } } // Add any new monitors that weren't in the existing collection foreach (var newMonitor in updatedMonitorsDict.Values) { Logger.LogInfo($"[ReloadMonitors] Adding new monitor: {newMonitor.Id}"); Monitors.Add(newMonitor); } } // Rebuild _settings.Properties.Monitors so visible items share refs // with _monitors (user toggles will be visible to save). Legacy entries // use the freshly-read instances; we never bind them to UI. _settings.Properties.Monitors = _monitors.Concat(legacyFromDisk).ToList(); Logger.LogInfo($"Successfully reloaded {updatedMonitors.Count} monitors"); } catch (Exception ex) { Logger.LogError($"Failed to reload monitors from settings: {ex.Message}"); } finally { _isReloading = false; } } private Func<string, int> SendConfigMSG { get; } private bool _isEnabled; private bool _isCrashLockActive; private PowerDisplaySettings _settings; private ObservableCollection<MonitorInfo> _monitors; private bool _hasMonitors; // True while ReloadMonitorsFromSettings is running. Suppresses PropertyChanged / // CollectionChanged-triggered saves and IPC signals so changes coming from disk // don't get written back as if they were user input. private bool _isReloading; // Profile-related fields private bool _suppressProfileSelectionPersistence; private ObservableCollection<PowerDisplayProfile> _profiles = new ObservableCollection<PowerDisplayProfile>(); // Custom VCP mapping fields private ObservableCollection<CustomVcpValueMapping> _customVcpMappings; /// <summary> /// Gets collection of custom VCP value name mappings /// </summary> public ObservableCollection<CustomVcpValueMapping> CustomVcpMappings => _customVcpMappings; /// <summary> /// Gets a value indicating whether there are any custom VCP mappings (for UI binding). /// </summary> public bool HasCustomVcpMappings => _customVcpMappings?.Count > 0; /// <summary> /// Gets collection of available profiles (for button display) /// </summary> public ObservableCollection<PowerDisplayProfile> Profiles => _profiles; /// <summary> /// Gets a value indicating whether there are any profiles (for UI binding). /// </summary> public bool HasProfiles => _profiles?.Count > 0; private void Profiles_CollectionChanged(object sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e) { if (_suppressProfileSelectionPersistence) { return; } OnPropertyChanged(nameof(HasProfiles)); } public bool IsProfilesLoading { get => _isProfilesLoading; private set { if (_isProfilesLoading == value) { return; } _isProfilesLoading = value; OnPropertyChanged(nameof(IsProfilesLoading)); OnPropertyChanged(nameof(CanUseProfiles)); } } public bool CanUseProfiles => IsEnabled && !IsProfilesLoading; public void RefreshEnabledState() { InitializeEnabledValue(); OnPropertyChanged(nameof(IsEnabled)); OnPropertyChanged(nameof(CanUseProfiles)); } private bool SetSettingsProperty<T>(T currentValue, T newValue, Action<T> setter, [CallerMemberName] string propertyName = null) { if (EqualityComparer<T>.Default.Equals(currentValue, newValue)) { return false; } setter(newValue); OnPropertyChanged(propertyName); NotifySettingsChanged(); return true; } public async Task InitializeProfilesAsync(CancellationToken cancellationToken = default) { if (IsProfilesLoading) { return; } IsProfilesLoading = true; _suppressProfileSelectionPersistence = true; try { var loaded = await LoadProfilesCoreAsync(cancellationToken); ReplaceProfiles(loaded); } catch (Exception ex) { Profiles.Clear(); Logger.LogError($"Failed to load profiles: {ex.Message}"); } finally { _suppressProfileSelectionPersistence = false; IsProfilesLoading = false; OnPropertyChanged(nameof(HasProfiles)); } } private static Task<PowerDisplayProfiles> LoadProfilesCoreAsync( CancellationToken cancellationToken) { return ProfileHelper.LoadProfilesAsync(cancellationToken); } private void ReplaceProfiles(PowerDisplayProfiles profilesData) { Profiles.Clear(); foreach (var profile in profilesData.GetAssignedProfiles()) { Profiles.Add(profile); } Logger.LogInfo($"Loaded {Profiles.Count} profiles"); } /// <summary> /// Apply a profile to monitors /// </summary> public void ApplyProfile(PowerDisplayProfile profile) { try { if (profile == null || !profile.IsValid()) { Logger.LogWarning("Invalid profile"); return; } Logger.LogInfo($"Applying profile: {profile.DisplayName}"); // Send custom action to trigger profile application // The profile id is passed via Named Pipe IPC to PowerDisplay.exe var actionMessage = new PowerDisplayActionMessage { Action = new PowerDisplayActionMessage.ActionData { PowerDisplay = new PowerDisplayActionMessage.PowerDisplayAction { ActionName = "ApplyProfile", Value = profile.Id.ToString(System.Globalization.CultureInfo.InvariantCulture), }, }, }; SendConfigMSG(JsonSerializer.Serialize(actionMessage, SettingsSerializationContext.Default.PowerDisplayActionMessage)); Logger.LogInfo($"Profile '{profile.DisplayName}' apply request sent via IPC"); } catch (Exception ex) { Logger.LogError($"Failed to apply profile: {ex.Message}"); } } public Task CreateProfileAsync(PowerDisplayProfile profile) => UpsertProfileAsync(profile, isNew: true); public Task UpdateProfileAsync(PowerDisplayProfile profile) => UpsertProfileAsync(profile, isNew: false); private async Task UpsertProfileAsync(PowerDisplayProfile profile, bool isNew) { if (profile == null || !profile.IsValid()) { Logger.LogWarning("Invalid profile"); return; } if (IsProfilesLoading) { Logger.LogWarning("A profile operation is already in progress"); return; } IsProfilesLoading = true; try { await ProfileHelper.AddOrUpdateProfileAsync(profile); var profiles = await LoadProfilesCoreAsync(CancellationToken.None); ReplaceProfiles(profiles); SignalSettingsUpdated(); } catch (Exception ex) { Profiles.Clear(); Logger.LogError($"Failed to {(isNew ? "create" : "update")} profile: {ex.Message}"); } finally { IsProfilesLoading = false; } } public async Task DeleteProfileAsync(int id) { if (id < 1) { return; } if (IsProfilesLoading) { Logger.LogWarning("A profile operation is already in progress"); return; } IsProfilesLoading = true; try { if (!await ProfileHelper.RemoveProfileByIdAsync(id)) { Logger.LogWarning($"Profile id {id} was not found"); return; } var profiles = await LoadProfilesCoreAsync(CancellationToken.None); ReplaceProfiles(profiles); SignalSettingsUpdated(); await ClearDeletedProfileReferencesAsync(id); } catch (Exception ex) { Profiles.Clear(); Logger.LogError($"Failed to delete profile: {ex.Message}"); } finally { IsProfilesLoading = false; } } private async Task ClearDeletedProfileReferencesAsync(int deletedProfileId) { try { var lightSwitch = await Task.Run( () => SettingsUtils.GetSettingsOrDefault<LightSwitchSettings>( LightSwitchSettings.ModuleName)); LightSwitchProfileSettingsUpdater.ClearDeletedProfileAndSend( lightSwitch, deletedProfileId, SendConfigMSG); } catch (Exception ex) { Logger.LogError( $"Failed to clear LightSwitch references for deleted profile id {deletedProfileId}: {ex.Message}"); } } /// <summary> /// Load custom VCP mappings from settings /// </summary> private void LoadCustomVcpMappings() { List<CustomVcpValueMapping> mappings; try { mappings = _settings.Properties.CustomVcpMappings ?? new List<CustomVcpValueMapping>(); Logger.LogInfo($"Loaded {mappings.Count} custom VCP mappings"); } catch (Exception ex) { Logger.LogError($"Failed to load custom VCP mappings: {ex.Message}"); mappings = new List<CustomVcpValueMapping>(); } _customVcpMappings = new ObservableCollection<CustomVcpValueMapping>(mappings); _customVcpMappings.CollectionChanged += (s, e) => OnPropertyChanged(nameof(HasCustomVcpMappings)); OnPropertyChanged(nameof(CustomVcpMappings)); OnPropertyChanged(nameof(HasCustomVcpMappings)); } /// <summary> /// Add a new custom VCP mapping. /// No duplicate checking - mappings are resolved by order (first match wins in VcpNames). /// </summary> public void AddCustomVcpMapping(CustomVcpValueMapping mapping) { if (mapping == null) { return; } CustomVcpMappings.Add(mapping); Logger.LogInfo($"Added custom VCP mapping: VCP=0x{mapping.VcpCode:X2}, Value=0x{mapping.Value:X2} -> {mapping.CustomName}"); SaveCustomVcpMappings(); } /// <summary> /// Update an existing custom VCP mapping /// </summary> public void UpdateCustomVcpMapping(CustomVcpValueMapping oldMapping, CustomVcpValueMapping newMapping) { if (oldMapping == null || newMapping == null) { return; } var index = CustomVcpMappings.IndexOf(oldMapping); if (index >= 0) { CustomVcpMappings[index] = newMapping; Logger.LogInfo($"Updated custom VCP mapping at index {index}"); SaveCustomVcpMappings(); } } /// <summary> /// Delete a custom VCP mapping /// </summary> public void DeleteCustomVcpMapping(CustomVcpValueMapping mapping) { if (mapping == null) { return; } if (CustomVcpMappings.Remove(mapping)) { Logger.LogInfo($"Deleted custom VCP mapping: VCP=0x{mapping.VcpCode:X2}, Value=0x{mapping.Value:X2}"); SaveCustomVcpMappings(); } } /// <summary> /// Save custom VCP mappings to settings /// </summary> private void SaveCustomVcpMappings() { _settings.Properties.CustomVcpMappings = CustomVcpMappings.ToList(); NotifySettingsChanged(); // Signal PowerDisplay to reload settings SignalSettingsUpdated(); } /// <summary> /// Re-read the flyout-owned runtime fields (linked brightness enabled + per-monitor /// exclusion list) from disk into <see cref="_settings"/> so a save originating from an /// unrelated Settings toggle does not overwrite them with the page's stale snapshot. /// These fields have no Settings UI surface — the PowerDisplay flyout is their sole editor. /// </summary> private void PreserveFlyoutOwnedState() { try { var current = SettingsUtils.GetSettingsOrDefault<PowerDisplaySettings>(PowerDisplaySettings.ModuleName); _settings.Properties.LinkedLevelsActive = current.Properties.LinkedLevelsActive; _settings.Properties.ExcludedFromSyncMonitorIds = current.Properties.ExcludedFromSyncMonitorIds; } catch (Exception ex) { Logger.LogError($"Failed to preserve flyout-owned PowerDisplay state before save: {ex.Message}"); } } private void NotifySettingsChanged() { // Skip during initialization when SendConfigMSG is not yet set if (SendConfigMSG == null) { return; } // linked_levels_active and excluded_from_sync_monitor_ids are owned by the PowerDisplay // flyout (the only UI that toggles them); the Settings page has no surface for them. // _settings was loaded once at page construction, so serializing it would otherwise // clobber flyout changes made meanwhile — both on disk and in the IPC config pushed // to the module. Re-read the current on-disk values and carry them forward untouched. PreserveFlyoutOwnedState(); // Persist locally first so settings survive even if the module DLL isn't loaded yet. SettingsUtils.SaveSettings(_settings.ToJsonString(), PowerDisplaySettings.ModuleName); // Using InvariantCulture as this is an IPC message // This message will be intercepted by the runner, which passes the serialized JSON to // PowerDisplay Module Interface's set_config() method, which then applies it in-process. SendConfigMSG( string.Format( CultureInfo.InvariantCulture, "{{ \"powertoys\": {{ \"{0}\": {1} }} }}", PowerDisplaySettings.ModuleName, _settings.ToJsonString())); } } }