diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Models/CustomVcpValueMapping.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Models/CustomVcpValueMapping.cs new file mode 100644 index 0000000000..65131af103 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Models/CustomVcpValueMapping.cs @@ -0,0 +1,88 @@ +// 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.Text.Json.Serialization; +using PowerDisplay.Common.Utils; + +namespace PowerDisplay.Common.Models +{ + /// + /// Represents a custom name mapping for a VCP code value. + /// Used to override the default VCP value names with user-defined names. + /// This class is shared between PowerDisplay app and Settings UI. + /// + public class CustomVcpValueMapping + { + /// + /// Gets or sets the VCP code (e.g., 0x14 for color temperature, 0x60 for input source). + /// + [JsonPropertyName("vcpCode")] + public byte VcpCode { get; set; } + + /// + /// Gets or sets the VCP value to map (e.g., 0x11 for HDMI-1). + /// + [JsonPropertyName("value")] + public int Value { get; set; } + + /// + /// Gets or sets the custom name to display instead of the default name. + /// + [JsonPropertyName("customName")] + public string CustomName { get; set; } = string.Empty; + + /// + /// Gets or sets a value indicating whether this mapping applies to all monitors. + /// When true, the mapping is applied globally. When false, only applies to TargetMonitorId. + /// + [JsonPropertyName("applyToAll")] + public bool ApplyToAll { get; set; } = true; + + /// + /// Gets or sets the target monitor ID when ApplyToAll is false. + /// This is the monitor's unique identifier. + /// + [JsonPropertyName("targetMonitorId")] + public string TargetMonitorId { get; set; } = string.Empty; + + /// + /// Gets or sets the target monitor display name (for UI display only, not serialized). + /// + [JsonIgnore] + public string TargetMonitorName { get; set; } = string.Empty; + + /// + /// Gets the display name for the VCP code (for UI display). + /// Uses VcpNames.GetCodeName() to get the standard MCCS VCP code name. + /// Note: For localized display in Settings UI, use VcpCodeToDisplayNameConverter instead. + /// + [JsonIgnore] + public string VcpCodeDisplayName => VcpNames.GetCodeName(VcpCode); + + /// + /// Gets the display name for the VCP value (using built-in mapping). + /// + [JsonIgnore] + public string ValueDisplayName => VcpNames.GetFormattedValueName(VcpCode, Value); + + /// + /// Gets a summary string for display in the UI list. + /// Format: "OriginalValue → CustomName" or "OriginalValue → CustomName (MonitorName)" + /// + [JsonIgnore] + public string DisplaySummary + { + get + { + var baseSummary = $"{VcpNames.GetValueName(VcpCode, Value) ?? $"0x{Value:X2}"} → {CustomName}"; + if (!ApplyToAll && !string.IsNullOrEmpty(TargetMonitorName)) + { + return $"{baseSummary} ({TargetMonitorName})"; + } + + return baseSummary; + } + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Utils/VcpNames.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Utils/VcpNames.cs index 2d4fed19c6..1ecd5f150e 100644 --- a/src/modules/powerdisplay/PowerDisplay.Lib/Utils/VcpNames.cs +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Utils/VcpNames.cs @@ -2,16 +2,27 @@ // 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.Linq; +using PowerDisplay.Common.Models; namespace PowerDisplay.Common.Utils { /// /// Provides human-readable names for VCP codes and their values based on MCCS v2.2a specification. /// Combines VCP code names (e.g., 0x10 = "Brightness") and VCP value names (e.g., 0x14:0x05 = "6500K"). + /// Supports localization through the LocalizedCodeNameProvider delegate. /// public static class VcpNames { + /// + /// Optional delegate to provide localized VCP code names. + /// Set this at application startup to enable localization. + /// The delegate receives a VCP code and should return the localized name, or null to use the default. + /// + public static Func? LocalizedCodeNameProvider { get; set; } + /// /// VCP code to name mapping /// @@ -237,12 +248,21 @@ namespace PowerDisplay.Common.Utils }; /// - /// Get the friendly name for a VCP code + /// Get the friendly name for a VCP code. + /// Uses LocalizedCodeNameProvider if set; falls back to built-in MCCS names if not. /// /// VCP code (e.g., 0x10) /// Friendly name, or hex representation if unknown public static string GetCodeName(byte code) { + // Try localized name first + var localizedName = LocalizedCodeNameProvider?.Invoke(code); + if (!string.IsNullOrEmpty(localizedName)) + { + return localizedName; + } + + // Fallback to built-in MCCS names return CodeNames.TryGetValue(code, out var name) ? name : $"Unknown (0x{code:X2})"; } @@ -389,6 +409,16 @@ namespace PowerDisplay.Common.Utils }, }; + /// + /// Get all known values for a VCP code + /// + /// VCP code (e.g., 0x14) + /// Dictionary of value to name mappings, or null if no mappings exist + public static IReadOnlyDictionary? GetValueMappings(byte vcpCode) + { + return ValueNames.TryGetValue(vcpCode, out var values) ? values : null; + } + /// /// Get human-readable name for a VCP value /// @@ -424,5 +454,59 @@ namespace PowerDisplay.Common.Utils return $"0x{value:X2}"; } + + /// + /// Get human-readable name for a VCP value with custom mapping support. + /// Custom mappings take priority over built-in mappings. + /// Monitor ID is required to properly filter monitor-specific mappings. + /// + /// VCP code (e.g., 0x14) + /// Value to translate + /// Optional custom mappings that take priority + /// Monitor ID to filter mappings + /// Name string like "sRGB" or null if unknown + public static string? GetValueName(byte vcpCode, int value, IEnumerable? customMappings, string monitorId) + { + // 1. Priority: Check custom mappings first + if (customMappings != null) + { + // Find a matching custom mapping: + // - ApplyToAll = true (global), OR + // - ApplyToAll = false AND TargetMonitorId matches the given monitorId + var custom = customMappings.FirstOrDefault(m => + m.VcpCode == vcpCode && + m.Value == value && + (m.ApplyToAll || (!m.ApplyToAll && m.TargetMonitorId == monitorId))); + + if (custom != null && !string.IsNullOrEmpty(custom.CustomName)) + { + return custom.CustomName; + } + } + + // 2. Fallback to built-in mappings + return GetValueName(vcpCode, value); + } + + /// + /// Get formatted display name for a VCP value with custom mapping support. + /// Custom mappings take priority over built-in mappings. + /// Monitor ID is required to properly filter monitor-specific mappings. + /// + /// VCP code (e.g., 0x14) + /// Value to translate + /// Optional custom mappings that take priority + /// Monitor ID to filter mappings + /// Formatted string like "sRGB (0x01)" or "0x01" if unknown + public static string GetFormattedValueName(byte vcpCode, int value, IEnumerable? customMappings, string monitorId) + { + var name = GetValueName(vcpCode, value, customMappings, monitorId); + if (name != null) + { + return $"{name} (0x{value:X2})"; + } + + return $"0x{value:X2}"; + } } } diff --git a/src/modules/powerdisplay/PowerDisplay/Program.cs b/src/modules/powerdisplay/PowerDisplay/Program.cs index f893741e77..2553637b6f 100644 --- a/src/modules/powerdisplay/PowerDisplay/Program.cs +++ b/src/modules/powerdisplay/PowerDisplay/Program.cs @@ -125,14 +125,27 @@ namespace PowerDisplay /// /// Called when an existing instance is activated by another process. - /// This happens when EnsureProcessRunning() launches a new process while one is already running. - /// We intentionally don't show the window here - window visibility should only be controlled via: - /// - Toggle event (hotkey, tray icon click, Settings UI Launch button) - /// - Standalone mode startup (handled in OnLaunched) + /// This happens when Quick Access or other launchers start the process while one is already running. + /// We toggle the window to show it - this allows Quick Access launch to work properly. /// private static void OnActivated(object? sender, AppActivationArguments args) { - Logger.LogInfo("OnActivated: Redirect activation received - window visibility unchanged"); + Logger.LogInfo("OnActivated: Redirect activation received - toggling window"); + + // Toggle the main window on redirect activation + if (_app?.MainWindow is MainWindow mainWindow) + { + // Dispatch to UI thread since OnActivated may be called from a different thread + mainWindow.DispatcherQueue.TryEnqueue(() => + { + Logger.LogTrace("OnActivated: Toggling window from redirect activation"); + mainWindow.ToggleWindow(); + }); + } + else + { + Logger.LogWarning("OnActivated: MainWindow not available for toggle"); + } } } } diff --git a/src/modules/powerdisplay/PowerDisplay/ViewModels/MainViewModel.Settings.cs b/src/modules/powerdisplay/PowerDisplay/ViewModels/MainViewModel.Settings.cs index dc9e7ccbbd..d01af35c6c 100644 --- a/src/modules/powerdisplay/PowerDisplay/ViewModels/MainViewModel.Settings.cs +++ b/src/modules/powerdisplay/PowerDisplay/ViewModels/MainViewModel.Settings.cs @@ -43,6 +43,10 @@ public partial class MainViewModel // UpdateMonitorList already handles filtering hidden monitors UpdateMonitorList(_monitorManager.Monitors, isInitialLoad: false); + // Reload UI display settings first (includes custom VCP mappings) + // Must be loaded before ApplyUIConfiguration so names are available for UI refresh + LoadUIDisplaySettings(); + // Apply UI configuration changes only (feature visibility toggles, etc.) // Hardware parameters (brightness, color temperature) are applied via custom actions var settings = _settingsUtils.GetSettingsOrDefault("PowerDisplay"); @@ -51,8 +55,11 @@ public partial class MainViewModel // Reload profiles in case they were added/updated/deleted in Settings UI LoadProfiles(); - // Reload UI display settings (profile switcher, identify button, color temp switcher) - LoadUIDisplaySettings(); + // Notify MonitorViewModels to refresh their custom VCP name displays + foreach (var monitor in Monitors) + { + monitor.RefreshCustomVcpNames(); + } } catch (Exception ex) { @@ -304,7 +311,8 @@ public partial class MainViewModel } /// - /// Apply feature visibility settings to a monitor ViewModel + /// Apply feature visibility settings to a monitor ViewModel. + /// Only shows features that are both enabled by user AND supported by hardware. /// private void ApplyFeatureVisibility(MonitorViewModel monitorVm, PowerDisplaySettings settings) { @@ -313,12 +321,13 @@ public partial class MainViewModel if (monitorSettings != null) { - monitorVm.ShowContrast = monitorSettings.EnableContrast; - monitorVm.ShowVolume = monitorSettings.EnableVolume; - monitorVm.ShowInputSource = monitorSettings.EnableInputSource; + // Only show features that are both enabled by user AND supported by hardware + monitorVm.ShowContrast = monitorSettings.EnableContrast && monitorVm.SupportsContrast; + monitorVm.ShowVolume = monitorSettings.EnableVolume && monitorVm.SupportsVolume; + monitorVm.ShowInputSource = monitorSettings.EnableInputSource && monitorVm.SupportsInputSource; monitorVm.ShowRotation = monitorSettings.EnableRotation; - monitorVm.ShowColorTemperature = monitorSettings.EnableColorTemperature; - monitorVm.ShowPowerState = monitorSettings.EnablePowerState; + monitorVm.ShowColorTemperature = monitorSettings.EnableColorTemperature && monitorVm.SupportsColorTemperature; + monitorVm.ShowPowerState = monitorSettings.EnablePowerState && monitorVm.SupportsPowerState; } } diff --git a/src/modules/powerdisplay/PowerDisplay/ViewModels/MainViewModel.cs b/src/modules/powerdisplay/PowerDisplay/ViewModels/MainViewModel.cs index d3a831a07d..e16b34cadb 100644 --- a/src/modules/powerdisplay/PowerDisplay/ViewModels/MainViewModel.cs +++ b/src/modules/powerdisplay/PowerDisplay/ViewModels/MainViewModel.cs @@ -163,6 +163,23 @@ public partial class MainViewModel : INotifyPropertyChanged, IDisposable } } + // Custom VCP mappings - loaded from settings + private List _customVcpMappings = new(); + + /// + /// Gets or sets the custom VCP value name mappings. + /// These mappings override the default VCP value names for color temperature and input source. + /// + public List CustomVcpMappings + { + get => _customVcpMappings; + set + { + _customVcpMappings = value ?? new List(); + OnPropertyChanged(); + } + } + public bool IsScanning { get => _isScanning; @@ -389,6 +406,10 @@ public partial class MainViewModel : INotifyPropertyChanged, IDisposable var settings = _settingsUtils.GetSettingsOrDefault(PowerDisplaySettings.ModuleName); ShowProfileSwitcher = settings.Properties.ShowProfileSwitcher; ShowIdentifyMonitorsButton = settings.Properties.ShowIdentifyMonitorsButton; + + // Load custom VCP mappings (now using shared type from PowerDisplay.Common.Models) + CustomVcpMappings = settings.Properties.CustomVcpMappings?.ToList() ?? new List(); + Logger.LogInfo($"[Settings] Loaded {CustomVcpMappings.Count} custom VCP mappings"); } catch (Exception ex) { diff --git a/src/modules/powerdisplay/PowerDisplay/ViewModels/MonitorViewModel.cs b/src/modules/powerdisplay/PowerDisplay/ViewModels/MonitorViewModel.cs index 5629ebdacf..b50fcc03e4 100644 --- a/src/modules/powerdisplay/PowerDisplay/ViewModels/MonitorViewModel.cs +++ b/src/modules/powerdisplay/PowerDisplay/ViewModels/MonitorViewModel.cs @@ -279,6 +279,16 @@ public partial class MonitorViewModel : INotifyPropertyChanged, IDisposable // Advanced control display logic public bool HasAdvancedControls => ShowContrast || ShowVolume; + /// + /// Gets a value indicating whether this monitor supports contrast control via VCP 0x12 + /// + public bool SupportsContrast => _monitor.SupportsContrast; + + /// + /// Gets a value indicating whether this monitor supports volume control via VCP 0x62 + /// + public bool SupportsVolume => _monitor.SupportsVolume; + public bool ShowContrast { get => _showContrast; @@ -456,8 +466,10 @@ public partial class MonitorViewModel : INotifyPropertyChanged, IDisposable /// /// Gets human-readable color temperature preset name (e.g., "6500K", "sRGB") + /// Uses custom mappings if available; falls back to built-in names if not. /// - public string ColorTemperaturePresetName => _monitor.ColorTemperaturePresetName; + public string ColorTemperaturePresetName => + Common.Utils.VcpNames.GetFormattedValueName(0x14, _monitor.CurrentColorTemperature, _mainViewModel?.CustomVcpMappings, _monitor.Id); /// /// Gets a value indicating whether this monitor supports color temperature via VCP 0x14 @@ -537,7 +549,7 @@ public partial class MonitorViewModel : INotifyPropertyChanged, IDisposable _availableColorPresets = presetValues.Select(value => new ColorTemperatureItem { VcpValue = value, - DisplayName = Common.Utils.VcpNames.GetFormattedValueName(0x14, value), + DisplayName = Common.Utils.VcpNames.GetFormattedValueName(0x14, value, _mainViewModel?.CustomVcpMappings, _monitor.Id), IsSelected = value == _monitor.CurrentColorTemperature, MonitorId = _monitor.Id, }).ToList(); @@ -557,8 +569,11 @@ public partial class MonitorViewModel : INotifyPropertyChanged, IDisposable /// /// Gets human-readable current input source name (e.g., "HDMI-1", "DisplayPort-1") + /// Uses custom mappings if available; falls back to built-in names if not. /// - public string CurrentInputSourceName => _monitor.InputSourceName; + public string CurrentInputSourceName => + Common.Utils.VcpNames.GetValueName(0x60, _monitor.CurrentInputSource, _mainViewModel?.CustomVcpMappings, _monitor.Id) + ?? $"Source 0x{_monitor.CurrentInputSource:X2}"; private List? _availableInputSources; @@ -593,7 +608,7 @@ public partial class MonitorViewModel : INotifyPropertyChanged, IDisposable _availableInputSources = supportedSources.Select(value => new InputSourceItem { Value = value, - Name = Common.Utils.VcpNames.GetValueName(0x60, value) ?? $"Source 0x{value:X2}", + Name = Common.Utils.VcpNames.GetValueName(0x60, value, _mainViewModel?.CustomVcpMappings, _monitor.Id) ?? $"Source 0x{value:X2}", SelectionVisibility = value == _monitor.CurrentInputSource ? Visibility.Visible : Visibility.Collapsed, MonitorId = _monitor.Id, }).ToList(); @@ -601,6 +616,23 @@ public partial class MonitorViewModel : INotifyPropertyChanged, IDisposable OnPropertyChanged(nameof(AvailableInputSources)); } + /// + /// Refresh custom VCP name displays after settings change. + /// Called when CustomVcpMappings is updated from Settings UI. + /// + public void RefreshCustomVcpNames() + { + // Refresh color temperature names + OnPropertyChanged(nameof(ColorTemperaturePresetName)); + _availableColorPresets = null; // Force rebuild with new custom names + OnPropertyChanged(nameof(AvailableColorPresets)); + + // Refresh input source names + OnPropertyChanged(nameof(CurrentInputSourceName)); + _availableInputSources = null; // Force rebuild with new custom names + OnPropertyChanged(nameof(AvailableInputSources)); + } + /// /// Set input source for this monitor /// diff --git a/src/modules/powerdisplay/PowerDisplayModuleInterface/PowerDisplayProcessManager.cpp b/src/modules/powerdisplay/PowerDisplayModuleInterface/PowerDisplayProcessManager.cpp index 6f35629d3b..cf9ab171c2 100644 --- a/src/modules/powerdisplay/PowerDisplayModuleInterface/PowerDisplayProcessManager.cpp +++ b/src/modules/powerdisplay/PowerDisplayModuleInterface/PowerDisplayProcessManager.cpp @@ -52,8 +52,11 @@ void PowerDisplayProcessManager::send_message(const std::wstring& message_type, { submit_task([this, message_type, message_arg] { // Ensure process is running before sending message - if (!is_process_running() && m_enabled) + // If process is not running, enable and start it - this allows Quick Access launch + // to work even when the module was not previously enabled + if (!is_process_running()) { + m_enabled = true; refresh(); } send_named_pipe_message(message_type, message_arg); diff --git a/src/modules/powerdisplay/PowerDisplayModuleInterface/dllmain.cpp b/src/modules/powerdisplay/PowerDisplayModuleInterface/dllmain.cpp index 871a8797ef..7360a34772 100644 --- a/src/modules/powerdisplay/PowerDisplayModuleInterface/dllmain.cpp +++ b/src/modules/powerdisplay/PowerDisplayModuleInterface/dllmain.cpp @@ -9,6 +9,8 @@ #include #include #include +#include +#include #include "resource.h" @@ -48,6 +50,11 @@ private: HANDLE m_hRefreshEvent = nullptr; HANDLE m_hSendSettingsTelemetryEvent = nullptr; + // Toggle event handle and listener thread for Quick Access support + HANDLE m_hToggleEvent = nullptr; + HANDLE m_hStopEvent = nullptr; // Manual-reset event to signal thread termination + std::thread m_toggleEventThread; + public: PowerDisplayModule() { @@ -62,16 +69,29 @@ public: m_hSendSettingsTelemetryEvent = CreateDefaultEvent(CommonSharedConstants::POWER_DISPLAY_SEND_SETTINGS_TELEMETRY_EVENT); Logger::trace(L"Created SEND_SETTINGS_TELEMETRY_EVENT: handle={}", reinterpret_cast(m_hSendSettingsTelemetryEvent)); - if (!m_hRefreshEvent || !m_hSendSettingsTelemetryEvent) + // Create Toggle event for Quick Access support + // This allows Quick Access to launch PowerDisplay even when module is not enabled + m_hToggleEvent = CreateDefaultEvent(CommonSharedConstants::TOGGLE_POWER_DISPLAY_EVENT); + Logger::trace(L"Created TOGGLE_EVENT: handle={}", reinterpret_cast(m_hToggleEvent)); + + // Create manual-reset stop event for clean thread termination + m_hStopEvent = CreateEventW(nullptr, TRUE, FALSE, nullptr); + Logger::trace(L"Created STOP_EVENT: handle={}", reinterpret_cast(m_hStopEvent)); + + if (!m_hRefreshEvent || !m_hSendSettingsTelemetryEvent || !m_hToggleEvent || !m_hStopEvent) { - Logger::error(L"Failed to create one or more event handles: Refresh={}, SettingsTelemetry={}", + Logger::error(L"Failed to create one or more event handles: Refresh={}, SettingsTelemetry={}, Toggle={}", reinterpret_cast(m_hRefreshEvent), - reinterpret_cast(m_hSendSettingsTelemetryEvent)); + reinterpret_cast(m_hSendSettingsTelemetryEvent), + reinterpret_cast(m_hToggleEvent)); } else { Logger::info(L"All Windows Events created successfully"); } + + // Start toggle event listener thread for Quick Access support + StartToggleEventListener(); } ~PowerDisplayModule() @@ -81,6 +101,9 @@ public: disable(); } + // Stop toggle event listener thread + StopToggleEventListener(); + // Clean up event handles if (m_hRefreshEvent) { @@ -92,6 +115,99 @@ public: CloseHandle(m_hSendSettingsTelemetryEvent); m_hSendSettingsTelemetryEvent = nullptr; } + if (m_hToggleEvent) + { + CloseHandle(m_hToggleEvent); + m_hToggleEvent = nullptr; + } + if (m_hStopEvent) + { + CloseHandle(m_hStopEvent); + m_hStopEvent = nullptr; + } + } + + void StartToggleEventListener() + { + if (!m_hToggleEvent || !m_hStopEvent) + { + return; + } + + // Reset stop event before starting thread + ResetEvent(m_hStopEvent); + + m_toggleEventThread = std::thread([this]() { + Logger::info(L"Toggle event listener thread started"); + + HANDLE handles[] = { m_hToggleEvent, m_hStopEvent }; + constexpr DWORD TOGGLE_EVENT_INDEX = 0; + constexpr DWORD STOP_EVENT_INDEX = 1; + + while (true) + { + // Wait indefinitely for either toggle event or stop event + DWORD result = WaitForMultipleObjects(2, handles, FALSE, INFINITE); + + if (result == WAIT_OBJECT_0 + TOGGLE_EVENT_INDEX) + { + Logger::trace(L"Toggle event received"); + TogglePowerDisplay(); + } + else if (result == WAIT_OBJECT_0 + STOP_EVENT_INDEX) + { + // Stop event signaled - exit the loop + Logger::trace(L"Stop event received, exiting toggle listener"); + break; + } + else + { + // WAIT_FAILED or unexpected result + Logger::warn(L"WaitForMultipleObjects returned unexpected result: {}", result); + break; + } + } + + Logger::info(L"Toggle event listener thread stopped"); + }); + } + + void StopToggleEventListener() + { + if (m_hStopEvent) + { + // Signal the stop event to wake up the waiting thread + SetEvent(m_hStopEvent); + } + + if (m_toggleEventThread.joinable()) + { + m_toggleEventThread.join(); + } + } + + /// + /// Toggle PowerDisplay window visibility. + /// If process is running, launches again to trigger redirect activation (OnActivated handles toggle). + /// If process is not running, starts it via Named Pipe and sends toggle message. + /// + void TogglePowerDisplay() + { + if (m_processManager.is_running()) + { + // Process running - launch to trigger single instance redirect, OnActivated will toggle + SHELLEXECUTEINFOW sei{ sizeof(sei) }; + sei.fMask = SEE_MASK_FLAG_NO_UI; + sei.lpFile = L"WinUI3Apps\\PowerToys.PowerDisplay.exe"; + sei.nShow = SW_SHOWNORMAL; + ShellExecuteExW(&sei); + } + else + { + // Process not running - start and send toggle via Named Pipe + m_processManager.send_message(CommonSharedConstants::POWER_DISPLAY_TOGGLE_MESSAGE); + } + Trace::ActivatePowerDisplay(); } virtual void destroy() override @@ -135,10 +251,7 @@ public: if (action_object.get_name() == L"Launch") { Logger::trace(L"Launch action received"); - - // Send Toggle message via Named Pipe (will start process if needed) - m_processManager.send_message(CommonSharedConstants::POWER_DISPLAY_TOGGLE_MESSAGE); - Trace::ActivatePowerDisplay(); + TogglePowerDisplay(); } else if (action_object.get_name() == L"RefreshMonitors") { diff --git a/src/settings-ui/Settings.UI.Controls/QuickAccess/QuickAccessLauncher.cs b/src/settings-ui/Settings.UI.Controls/QuickAccess/QuickAccessLauncher.cs index b81c9638f3..1347ce86c1 100644 --- a/src/settings-ui/Settings.UI.Controls/QuickAccess/QuickAccessLauncher.cs +++ b/src/settings-ui/Settings.UI.Controls/QuickAccess/QuickAccessLauncher.cs @@ -119,6 +119,13 @@ namespace Microsoft.PowerToys.Settings.UI.Controls eventHandle.Set(); } + return true; + case ModuleType.PowerDisplay: + using (var eventHandle = new EventWaitHandle(false, EventResetMode.AutoReset, Constants.TogglePowerDisplayEvent())) + { + eventHandle.Set(); + } + return true; default: return false; diff --git a/src/settings-ui/Settings.UI.Library/PowerDisplayProperties.cs b/src/settings-ui/Settings.UI.Library/PowerDisplayProperties.cs index 25a4354474..5539daf0fd 100644 --- a/src/settings-ui/Settings.UI.Library/PowerDisplayProperties.cs +++ b/src/settings-ui/Settings.UI.Library/PowerDisplayProperties.cs @@ -23,6 +23,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library ShowSystemTrayIcon = true; ShowProfileSwitcher = true; ShowIdentifyMonitorsButton = true; + CustomVcpMappings = new List(); // Note: saved_monitor_settings has been moved to monitor_state.json // which is managed separately by PowerDisplay app @@ -61,5 +62,12 @@ namespace Microsoft.PowerToys.Settings.UI.Library /// [JsonPropertyName("show_identify_monitors_button")] public bool ShowIdentifyMonitorsButton { get; set; } + + /// + /// Gets or sets custom VCP value name mappings shared across all monitors. + /// Allows users to define custom names for color temperature presets and input sources. + /// + [JsonPropertyName("custom_vcp_mappings")] + public List CustomVcpMappings { get; set; } } } diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/CustomVcpMappingEditorDialog.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/CustomVcpMappingEditorDialog.xaml new file mode 100644 index 0000000000..9f0bc51079 --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/CustomVcpMappingEditorDialog.xaml @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/CustomVcpMappingEditorDialog.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/CustomVcpMappingEditorDialog.xaml.cs new file mode 100644 index 0000000000..915c891ada --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/CustomVcpMappingEditorDialog.xaml.cs @@ -0,0 +1,421 @@ +// 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. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Globalization; +using System.Linq; +using System.Runtime.CompilerServices; +using Microsoft.PowerToys.Settings.UI.Helpers; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using PowerDisplay.Common.Models; +using PowerDisplay.Common.Utils; + +namespace Microsoft.PowerToys.Settings.UI.Views +{ + /// + /// Dialog for creating/editing custom VCP value name mappings + /// + public sealed partial class CustomVcpMappingEditorDialog : ContentDialog, INotifyPropertyChanged + { + /// + /// Special value to indicate "Custom value" option in the ComboBox + /// + private const int CustomValueMarker = -1; + + /// + /// Represents a selectable VCP value item in the Value ComboBox + /// + public class VcpValueItem + { + public int Value { get; set; } + + public string DisplayName { get; set; } = string.Empty; + + public bool IsCustomOption => Value == CustomValueMarker; + } + + /// + /// Represents a selectable monitor item in the Monitor ComboBox + /// + public class MonitorItem + { + public string Id { get; set; } = string.Empty; + + public string DisplayName { get; set; } = string.Empty; + } + + private readonly IEnumerable? _monitors; + private ObservableCollection _availableValues = new(); + private ObservableCollection _availableMonitors = new(); + private byte _selectedVcpCode; + private int _selectedValue; + private string _customName = string.Empty; + private bool _canSave; + private bool _showCustomValueInput; + private bool _showMonitorSelector; + private int _customValueParsed; + private bool _applyToAll = true; + private string _selectedMonitorId = string.Empty; + private string _selectedMonitorName = string.Empty; + + public CustomVcpMappingEditorDialog(IEnumerable? monitors) + { + _monitors = monitors; + this.InitializeComponent(); + + // Set localized strings for ContentDialog + var resourceLoader = ResourceLoaderInstance.ResourceLoader; + Title = resourceLoader.GetString("PowerDisplay_CustomMappingEditor_Title"); + PrimaryButtonText = resourceLoader.GetString("PowerDisplay_Dialog_Save"); + CloseButtonText = resourceLoader.GetString("PowerDisplay_Dialog_Cancel"); + + // Set VCP code ComboBox items content dynamically using localized names + VcpCodeItem_0x14.Content = GetFormattedVcpCodeName(resourceLoader, 0x14); + VcpCodeItem_0x60.Content = GetFormattedVcpCodeName(resourceLoader, 0x60); + + // Populate monitor list + PopulateMonitorList(); + + // Default to Color Temperature (0x14) + VcpCodeComboBox.SelectedIndex = 0; + } + + /// + /// Gets the result mapping after dialog closes with Primary button + /// + public CustomVcpValueMapping? ResultMapping { get; private set; } + + /// + /// Gets the available values for the selected VCP code + /// + public ObservableCollection AvailableValues + { + get => _availableValues; + private set + { + _availableValues = value; + OnPropertyChanged(); + } + } + + /// + /// Gets the available monitors for selection + /// + public ObservableCollection AvailableMonitors + { + get => _availableMonitors; + private set + { + _availableMonitors = value; + OnPropertyChanged(); + } + } + + /// + /// Gets a value indicating whether the dialog can be saved + /// + public bool CanSave + { + get => _canSave; + private set + { + if (_canSave != value) + { + _canSave = value; + OnPropertyChanged(); + } + } + } + + /// + /// Gets a value indicating whether to show the custom value input TextBox + /// + public Visibility ShowCustomValueInput => _showCustomValueInput ? Visibility.Visible : Visibility.Collapsed; + + /// + /// Gets a value indicating whether to show the monitor selector ComboBox + /// + public Visibility ShowMonitorSelector => _showMonitorSelector ? Visibility.Visible : Visibility.Collapsed; + + private void SetShowCustomValueInput(bool value) + { + if (_showCustomValueInput != value) + { + _showCustomValueInput = value; + OnPropertyChanged(nameof(ShowCustomValueInput)); + } + } + + private void SetShowMonitorSelector(bool value) + { + if (_showMonitorSelector != value) + { + _showMonitorSelector = value; + OnPropertyChanged(nameof(ShowMonitorSelector)); + } + } + + private void PopulateMonitorList() + { + AvailableMonitors = new ObservableCollection( + _monitors?.Select(m => new MonitorItem { Id = m.Id, DisplayName = m.DisplayName }) + ?? Enumerable.Empty()); + + if (AvailableMonitors.Count > 0) + { + MonitorComboBox.SelectedIndex = 0; + } + } + + /// + /// Pre-fill the dialog with existing mapping data for editing + /// + public void PreFillMapping(CustomVcpValueMapping mapping) + { + if (mapping is null) + { + return; + } + + // Select the VCP code + VcpCodeComboBox.SelectedIndex = mapping.VcpCode == 0x14 ? 0 : 1; + + // Populate values for the selected VCP code + PopulateValuesForVcpCode(mapping.VcpCode); + + // Try to select the value in the ComboBox + var matchingItem = AvailableValues.FirstOrDefault(v => !v.IsCustomOption && v.Value == mapping.Value); + if (matchingItem is not null) + { + ValueComboBox.SelectedItem = matchingItem; + } + else + { + // Value not found in list, select "Custom value" option and fill the TextBox + ValueComboBox.SelectedItem = AvailableValues.FirstOrDefault(v => v.IsCustomOption); + CustomValueTextBox.Text = $"0x{mapping.Value:X2}"; + _customValueParsed = mapping.Value; + } + + // Set the custom name + CustomNameTextBox.Text = mapping.CustomName; + _customName = mapping.CustomName; + + // Set apply scope + _applyToAll = mapping.ApplyToAll; + ApplyToAllToggle.IsOn = mapping.ApplyToAll; + SetShowMonitorSelector(!mapping.ApplyToAll); + + // Select the target monitor if not applying to all + if (!mapping.ApplyToAll && !string.IsNullOrEmpty(mapping.TargetMonitorId)) + { + var targetMonitor = AvailableMonitors.FirstOrDefault(m => m.Id == mapping.TargetMonitorId); + if (targetMonitor is not null) + { + MonitorComboBox.SelectedItem = targetMonitor; + _selectedMonitorId = targetMonitor.Id; + _selectedMonitorName = targetMonitor.DisplayName; + } + } + + UpdateCanSave(); + } + + private void VcpCodeComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (VcpCodeComboBox.SelectedItem is ComboBoxItem selectedItem && + selectedItem.Tag is string tagValue && + byte.TryParse(tagValue, out byte vcpCode)) + { + _selectedVcpCode = vcpCode; + PopulateValuesForVcpCode(vcpCode); + UpdateCanSave(); + } + } + + private void PopulateValuesForVcpCode(byte vcpCode) + { + var values = new ObservableCollection(); + var seenValues = new HashSet(); + + // Collect values from all monitors + if (_monitors is not null) + { + foreach (var monitor in _monitors) + { + if (monitor.VcpCodesFormatted is null) + { + continue; + } + + // Find the VCP code entry + var vcpEntry = monitor.VcpCodesFormatted.FirstOrDefault(v => + !string.IsNullOrEmpty(v.Code) && + TryParseHexCode(v.Code, out int code) && + code == vcpCode); + + if (vcpEntry?.ValueList is null) + { + continue; + } + + // Add each value from this monitor + foreach (var valueInfo in vcpEntry.ValueList) + { + if (TryParseHexCode(valueInfo.Value, out int vcpValue) && !seenValues.Contains(vcpValue)) + { + seenValues.Add(vcpValue); + var displayName = !string.IsNullOrEmpty(valueInfo.Name) + ? $"{valueInfo.Name} (0x{vcpValue:X2})" + : VcpNames.GetFormattedValueName(vcpCode, vcpValue); + values.Add(new VcpValueItem + { + Value = vcpValue, + DisplayName = displayName, + }); + } + } + } + } + + // If no values found from monitors, fall back to built-in values from VcpNames + if (values.Count == 0) + { + var builtInValues = VcpNames.GetValueMappings(vcpCode); + if (builtInValues is not null) + { + foreach (var kvp in builtInValues) + { + values.Add(new VcpValueItem + { + Value = kvp.Key, + DisplayName = $"{kvp.Value} (0x{kvp.Key:X2})", + }); + } + } + } + + // Sort by value + var sortedValues = new ObservableCollection(values.OrderBy(v => v.Value)); + + // Add "Custom value" option at the end + var resourceLoader = ResourceLoaderInstance.ResourceLoader; + sortedValues.Add(new VcpValueItem + { + Value = CustomValueMarker, + DisplayName = resourceLoader.GetString("PowerDisplay_CustomMappingEditor_CustomValueOption"), + }); + + AvailableValues = sortedValues; + + // Select first item if available + if (sortedValues.Count > 0) + { + ValueComboBox.SelectedIndex = 0; + } + } + + private static bool TryParseHexCode(string? hex, out int result) + { + result = 0; + if (string.IsNullOrEmpty(hex)) + { + return false; + } + + var cleanHex = hex.StartsWith("0x", StringComparison.OrdinalIgnoreCase) ? hex[2..] : hex; + return int.TryParse(cleanHex, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out result); + } + + private static string GetFormattedVcpCodeName(Windows.ApplicationModel.Resources.ResourceLoader resourceLoader, byte vcpCode) + { + var resourceKey = $"PowerDisplay_VcpCode_Name_0x{vcpCode:X2}"; + var localizedName = resourceLoader.GetString(resourceKey); + var name = string.IsNullOrEmpty(localizedName) ? VcpNames.GetCodeName(vcpCode) : localizedName; + return $"{name} (0x{vcpCode:X2})"; + } + + private void ValueComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (ValueComboBox.SelectedItem is VcpValueItem selectedItem) + { + SetShowCustomValueInput(selectedItem.IsCustomOption); + _selectedValue = selectedItem.IsCustomOption ? 0 : selectedItem.Value; + UpdateCanSave(); + } + } + + private void CustomValueTextBox_TextChanged(object sender, TextChangedEventArgs e) + { + _customValueParsed = TryParseHexCode(CustomValueTextBox.Text?.Trim(), out int parsed) ? parsed : 0; + UpdateCanSave(); + } + + private void CustomNameTextBox_TextChanged(object sender, TextChangedEventArgs e) + { + _customName = CustomNameTextBox.Text?.Trim() ?? string.Empty; + UpdateCanSave(); + } + + private void ApplyToAllToggle_Toggled(object sender, RoutedEventArgs e) + { + _applyToAll = ApplyToAllToggle.IsOn; + SetShowMonitorSelector(!_applyToAll); + UpdateCanSave(); + } + + private void MonitorComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (MonitorComboBox.SelectedItem is MonitorItem selectedMonitor) + { + _selectedMonitorId = selectedMonitor.Id; + _selectedMonitorName = selectedMonitor.DisplayName; + UpdateCanSave(); + } + } + + private void UpdateCanSave() + { + var hasValidValue = _showCustomValueInput + ? _customValueParsed > 0 + : ValueComboBox.SelectedItem is VcpValueItem item && !item.IsCustomOption; + + CanSave = _selectedVcpCode > 0 && + hasValidValue && + !string.IsNullOrWhiteSpace(_customName) && + (_applyToAll || !string.IsNullOrEmpty(_selectedMonitorId)); + } + + private void ContentDialog_PrimaryButtonClick(ContentDialog sender, ContentDialogButtonClickEventArgs args) + { + if (CanSave) + { + int finalValue = _showCustomValueInput ? _customValueParsed : _selectedValue; + ResultMapping = new CustomVcpValueMapping + { + VcpCode = _selectedVcpCode, + Value = finalValue, + CustomName = _customName, + ApplyToAll = _applyToAll, + TargetMonitorId = _applyToAll ? string.Empty : _selectedMonitorId, + TargetMonitorName = _applyToAll ? string.Empty : _selectedMonitorName, + }; + } + } + + public event PropertyChangedEventHandler? PropertyChanged; + + private void OnPropertyChanged([CallerMemberName] string? propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + } +} diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerDisplayPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerDisplayPage.xaml index 08d736a0fc..899295ec66 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerDisplayPage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerDisplayPage.xaml @@ -63,11 +63,51 @@ + + + + + + + + + + + diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerDisplayPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerDisplayPage.xaml.cs index 641311daad..d82746faf6 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerDisplayPage.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerDisplayPage.xaml.cs @@ -133,6 +133,65 @@ namespace Microsoft.PowerToys.Settings.UI.Views return ProfileHelper.GenerateUniqueProfileName(existingNames, baseName); } + // Custom VCP Mapping event handlers + private async void AddCustomMapping_Click(object sender, RoutedEventArgs e) + { + var dialog = new CustomVcpMappingEditorDialog(ViewModel.Monitors); + dialog.XamlRoot = this.XamlRoot; + + var result = await dialog.ShowAsync(); + + if (result == ContentDialogResult.Primary && dialog.ResultMapping != null) + { + ViewModel.AddCustomVcpMapping(dialog.ResultMapping); + } + } + + private async void EditCustomMapping_Click(object sender, RoutedEventArgs e) + { + if (sender is not Button button || button.Tag is not CustomVcpValueMapping mapping) + { + return; + } + + var dialog = new CustomVcpMappingEditorDialog(ViewModel.Monitors); + dialog.XamlRoot = this.XamlRoot; + dialog.PreFillMapping(mapping); + + var result = await dialog.ShowAsync(); + + if (result == ContentDialogResult.Primary && dialog.ResultMapping != null) + { + ViewModel.UpdateCustomVcpMapping(mapping, dialog.ResultMapping); + } + } + + private async void DeleteCustomMapping_Click(object sender, RoutedEventArgs e) + { + if (sender is not Button button || button.Tag is not CustomVcpValueMapping mapping) + { + return; + } + + var resourceLoader = ResourceLoaderInstance.ResourceLoader; + var dialog = new ContentDialog + { + XamlRoot = this.XamlRoot, + Title = resourceLoader.GetString("PowerDisplay_CustomMapping_Delete_Title"), + Content = resourceLoader.GetString("PowerDisplay_CustomMapping_Delete_Message"), + PrimaryButtonText = resourceLoader.GetString("Yes"), + CloseButtonText = resourceLoader.GetString("No"), + DefaultButton = ContentDialogButton.Close, + }; + + var result = await dialog.ShowAsync(); + + if (result == ContentDialogResult.Primary) + { + ViewModel.DeleteCustomVcpMapping(mapping); + } + } + // Flag to prevent reentrant handling during programmatic checkbox changes private bool _isRestoringColorTempCheckbox; diff --git a/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw b/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw index ff7e61cccd..a11cbd72bc 100644 --- a/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw +++ b/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw @@ -5995,6 +5995,69 @@ The break timer font matches the text font. Show or hide the identify monitors button in the Power Display flyout + + Custom VCP Name Mappings + + + Custom name mappings + + + Define custom display names for color temperature presets and input sources + + + Add custom mapping + + + Add mapping + + + Custom VCP Name Mapping + + + VCP Code + + + Color Temperature + + + Input Source + + + Custom Name + + + Enter custom name + + + Value + + + Custom value... + + + Enter custom value (hex) + + + e.g., 0x11 or 17 + + + Apply to all monitors + + + On + + + Off + + + Select monitor + + + Delete custom mapping? + + + This custom name mapping will be permanently removed. + Backup diff --git a/src/settings-ui/Settings.UI/ViewModels/PowerDisplayViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/PowerDisplayViewModel.cs index ce19e37467..6c96214d5b 100644 --- a/src/settings-ui/Settings.UI/ViewModels/PowerDisplayViewModel.cs +++ b/src/settings-ui/Settings.UI/ViewModels/PowerDisplayViewModel.cs @@ -36,6 +36,9 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels public PowerDisplayViewModel(SettingsUtils settingsUtils, ISettingsRepository settingsRepository, ISettingsRepository powerDisplaySettingsRepository, Func ipcMSGCallBackFunc) { + // Set up localized VCP code names for UI display + VcpNames.LocalizedCodeNameProvider = GetLocalizedVcpCodeName; + // To obtain the general settings configurations of PowerToys Settings. ArgumentNullException.ThrowIfNull(settingsRepository); @@ -56,9 +59,15 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels // set the callback functions value to handle outgoing IPC message. SendConfigMSG = ipcMSGCallBackFunc; + // Subscribe to collection changes for HasProfiles binding + _profiles.CollectionChanged += (s, e) => OnPropertyChanged(nameof(HasProfiles)); + // Load profiles LoadProfiles(); + // Load custom VCP mappings + LoadCustomVcpMappings(); + // Listen for monitor refresh events from PowerDisplay.exe NativeEventWaiter.WaitForEventLoop( Constants.RefreshPowerDisplayMonitorsEvent(), @@ -446,21 +455,28 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels // Profile-related fields private ObservableCollection _profiles = new ObservableCollection(); + // Custom VCP mapping fields + private ObservableCollection _customVcpMappings; + /// - /// Gets or sets collection of available profiles (for button display) + /// Gets collection of custom VCP value name mappings /// - public ObservableCollection Profiles - { - get => _profiles; - set - { - if (_profiles != value) - { - _profiles = value; - OnPropertyChanged(); - } - } - } + public ObservableCollection CustomVcpMappings => _customVcpMappings; + + /// + /// Gets whether there are any custom VCP mappings (for UI binding) + /// + public bool HasCustomVcpMappings => _customVcpMappings?.Count > 0; + + /// + /// Gets collection of available profiles (for button display) + /// + public ObservableCollection Profiles => _profiles; + + /// + /// Gets whether there are any profiles (for UI binding) + /// + public bool HasProfiles => _profiles?.Count > 0; public void RefreshEnabledState() { @@ -646,6 +662,109 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels } } + /// + /// Load custom VCP mappings from settings + /// + private void LoadCustomVcpMappings() + { + List mappings; + try + { + mappings = _settings.Properties.CustomVcpMappings ?? new List(); + Logger.LogInfo($"Loaded {mappings.Count} custom VCP mappings"); + } + catch (Exception ex) + { + Logger.LogError($"Failed to load custom VCP mappings: {ex.Message}"); + mappings = new List(); + } + + _customVcpMappings = new ObservableCollection(mappings); + _customVcpMappings.CollectionChanged += (s, e) => OnPropertyChanged(nameof(HasCustomVcpMappings)); + OnPropertyChanged(nameof(CustomVcpMappings)); + OnPropertyChanged(nameof(HasCustomVcpMappings)); + } + + /// + /// Add a new custom VCP mapping. + /// No duplicate checking - mappings are resolved by order (first match wins in VcpNames). + /// + 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(); + } + + /// + /// Update an existing custom VCP mapping + /// + 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(); + } + } + + /// + /// Delete a custom VCP mapping + /// + 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(); + } + } + + /// + /// Save custom VCP mappings to settings + /// + private void SaveCustomVcpMappings() + { + _settings.Properties.CustomVcpMappings = CustomVcpMappings.ToList(); + NotifySettingsChanged(); + + // Signal PowerDisplay to reload settings + SignalSettingsUpdated(); + } + + /// + /// Provides localized VCP code names for UI display. + /// Looks for resource string with pattern "PowerDisplay_VcpCode_Name_0xXX". + /// Returns null for unknown codes to use the default MCCS name. + /// +#nullable enable + private static string? GetLocalizedVcpCodeName(byte vcpCode) + { + var resourceKey = $"PowerDisplay_VcpCode_Name_0x{vcpCode:X2}"; + var localizedName = ResourceLoaderInstance.ResourceLoader.GetString(resourceKey); + + // ResourceLoader returns empty string if key not found + return string.IsNullOrEmpty(localizedName) ? null : localizedName; + } +#nullable restore + private void NotifySettingsChanged() { // Skip during initialization when SendConfigMSG is not yet set