/
githubmirror
/
PowerToys
Обзор
Документация
Войти
/
githubmirror
/
PowerToys
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
src/modules/powerdisplay/PowerDisplay/ViewModels/MonitorViewModel.cs
1 004 строки
36 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.ComponentModel; using System.Linq; using System.Threading; using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using ManagedCommon; using Microsoft.UI.Dispatching; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Controls.Primitives; using Microsoft.UI.Xaml.Input; using PowerDisplay.Common.Models; using PowerDisplay.Common.Services; using PowerDisplay.Configuration; using PowerDisplay.Helpers; using PowerDisplay.Models; using Windows.System; using DispatcherQueue = Microsoft.UI.Dispatching.DispatcherQueue; using DispatcherQueueTimer = Microsoft.UI.Dispatching.DispatcherQueueTimer; using Monitor = PowerDisplay.Common.Models.Monitor; namespace PowerDisplay.ViewModels; /// <summary> /// ViewModel for individual monitor /// </summary> public partial class MonitorViewModel : ObservableObject, IDisposable { private readonly Monitor _monitor; private readonly MonitorManager _monitorManager; private readonly MainViewModel? _mainViewModel; private int _brightness; private int _contrast; private int _volume; // Debounce timers — each user-driven setter restarts the matching timer so a drag // or held arrow key collapses into a single DDC/CI write once the user stops moving. // External / programmatic apply paths (SetBrightnessAsync etc.) bypass the setters // and therefore never schedule a debounced commit. private DispatcherQueueTimer? _brightnessCommitTimer; private DispatcherQueueTimer? _contrastCommitTimer; private DispatcherQueueTimer? _volumeCommitTimer; // Visibility settings (controlled by Settings UI) [ObservableProperty] [NotifyPropertyChangedFor(nameof(ShowBrightnessSlider))] public partial bool ShowBrightness { get; set; } [ObservableProperty] [NotifyPropertyChangedFor(nameof(HasAdvancedControls))] public partial bool ShowContrast { get; set; } [ObservableProperty] [NotifyPropertyChangedFor(nameof(HasAdvancedControls))] public partial bool ShowVolume { get; set; } [ObservableProperty] [NotifyPropertyChangedFor(nameof(ShowMoreButton))] [NotifyPropertyChangedFor(nameof(ShowSeparatorAfterInputSource))] public partial bool ShowInputSource { get; set; } [ObservableProperty] public partial bool ShowRotation { get; set; } private bool _showPowerState; /// <summary> /// Apply brightness with hardware update and state persistence. /// </summary> /// <param name="brightness">Brightness value (0-100)</param> public async Task SetBrightnessAsync(int brightness) { brightness = Math.Clamp(brightness, 0, 100); // Update UI state immediately if (_brightness != brightness) { _brightness = brightness; OnPropertyChanged(nameof(Brightness)); } await ApplyPropertyToHardwareAsync(nameof(Brightness), brightness, _monitorManager.SetBrightnessAsync); } /// <summary> /// Apply contrast with hardware update and state persistence. /// </summary> public async Task SetContrastAsync(int contrast) { contrast = Math.Clamp(contrast, 0, 100); if (_contrast != contrast) { _contrast = contrast; OnPropertyChanged(nameof(Contrast)); } await ApplyPropertyToHardwareAsync(nameof(Contrast), contrast, _monitorManager.SetContrastAsync); } /// <summary> /// Apply volume with hardware update and state persistence. /// </summary> public async Task SetVolumeAsync(int volume) { volume = Math.Clamp(volume, 0, 100); if (_volume != volume) { _volume = volume; OnPropertyChanged(nameof(Volume)); } await ApplyPropertyToHardwareAsync(nameof(Volume), volume, _monitorManager.SetVolumeAsync); } private bool IsDiscreteValueSupported(byte vcpCode, int value) { var vcpInfo = VcpCapabilitiesInfo; if (vcpInfo == null || !vcpInfo.SupportedVcpCodes.TryGetValue(vcpCode, out var codeInfo) || !codeInfo.HasDiscreteValues || !codeInfo.SupportedValues.Contains(value)) { Logger.LogWarning($"[{Id}] VCP 0x{vcpCode:X2} value 0x{value:X2} not in reported supported values, skipping"); return false; } return true; } /// <summary> /// Unified method to apply color temperature with hardware update and state persistence. /// Always immediate (no debouncing for discrete preset values). /// </summary> public async Task SetColorTemperatureAsync(int colorTemperature) { if (!IsDiscreteValueSupported(0x14, colorTemperature)) { return; } try { var result = await _monitorManager.SetColorTemperatureAsync(Id, colorTemperature); if (result.IsSuccess) { _monitor.CurrentColorTemperature = colorTemperature; OnPropertyChanged(nameof(ColorTemperature)); OnPropertyChanged(nameof(ColorTemperaturePresetName)); // Refresh the color presets list to update IsSelected checkmarks in UI RefreshAvailableColorPresets(); _mainViewModel?.SaveMonitorSettingDirect(_monitor.Id, nameof(ColorTemperature), colorTemperature); } else { Logger.LogWarning($"[{Id}] Failed to set color temperature: {result.ErrorMessage}"); } } catch (Exception ex) { Logger.LogError($"[{Id}] Exception setting color temperature: {ex.Message}"); } } /// <summary> /// Generic method to apply a monitor property to hardware and persist state. /// Consolidates common logic for brightness, contrast, and volume operations. /// </summary> /// <param name="propertyName">Name of the property being set (for logging and state persistence)</param> /// <param name="value">Value to apply</param> /// <param name="setAsyncFunc">Async function to call on MonitorManager</param> private async Task<bool> ApplyPropertyToHardwareAsync( string propertyName, int value, Func<string, int, CancellationToken, Task<MonitorOperationResult>> setAsyncFunc) { try { var result = await setAsyncFunc(Id, value, default); if (result.IsSuccess) { _mainViewModel?.SaveMonitorSettingDirect(_monitor.Id, propertyName, value); return true; } else { Logger.LogWarning($"[{Id}] Failed to set {propertyName.ToLowerInvariant()}: {result.ErrorMessage}"); } } catch (Exception ex) { Logger.LogError($"[{Id}] Exception setting {propertyName.ToLowerInvariant()}: {ex.Message}"); } return false; } // Property to access IsInteractionEnabled from parent ViewModel public bool IsInteractionEnabled => _mainViewModel?.IsInteractionEnabled ?? true; /// <summary> /// Gets the shared per-mouse-wheel-notch step for this monitor's sliders, proxied from the /// owning <see cref="MainViewModel"/>. Falls back to 5 if the owner is unavailable. /// </summary> public int MouseWheelIncrement => _mainViewModel?.MouseWheelIncrement ?? 5; public MonitorViewModel(Monitor monitor, MonitorManager monitorManager, MainViewModel mainViewModel) { _monitor = monitor; _monitorManager = monitorManager; _mainViewModel = mainViewModel; // Subscribe to MainViewModel property changes to update IsInteractionEnabled if (_mainViewModel != null) { _mainViewModel.PropertyChanged += OnMainViewModelPropertyChanged; } // Subscribe to underlying Monitor property changes (e.g., Orientation updates in mirror mode) _monitor.PropertyChanged += OnMonitorPropertyChanged; // Initialize Show properties for first-time detection. ApplyFeatureVisibility will // override these whenever settings.json has a saved entry for this monitor, so these // values only take effect for brand-new monitors (no persisted preference yet). // Mirror CreateMonitorInfo's defaults to keep the flyout and settings.json in sync: // - Brightness / Contrast / Volume: enabled if the hardware advertises the VCP code. // - InputSource / ColorTemperature / PowerState: always disabled by default (dangerous // features); the user opts in via the Settings UI confirmation dialog. ShowBrightness = monitor.SupportsBrightness; ShowContrast = monitor.SupportsContrast; ShowVolume = monitor.SupportsVolume; ShowInputSource = false; _showPowerState = false; _showColorTemperature = false; // Initialize basic properties from monitor _brightness = monitor.CurrentBrightness; _contrast = monitor.CurrentContrast; _volume = monitor.CurrentVolume; } public string Id => _monitor.Id; public string Name => IsInternal ? ResourceLoaderInstance.ResourceLoader.GetString("BuiltInDisplayName") : _monitor.Name; /// <summary> /// Gets the monitor number from the underlying monitor model (Windows DISPLAY number) /// </summary> public int MonitorNumber => _monitor.MonitorNumber; /// <summary> /// Gets the GDI display source name used to match the Windows primary display. /// </summary> public string GdiDeviceName => _monitor.GdiDeviceName; /// <summary> /// Gets the display name - includes monitor number when multiple monitors exist. /// Follows the same logic as Settings UI's MonitorInfo.DisplayName for consistency. /// </summary> public string DisplayName { get { var monitorCount = _mainViewModel?.Monitors?.Count ?? 0; // Show monitor number only when there are multiple monitors and MonitorNumber is valid if (monitorCount > 1 && MonitorNumber > 0) { return $"{Name} {MonitorNumber}"; } return Name; } } public string CommunicationMethod => _monitor.CommunicationMethod; public bool IsInternal => _monitor.CommunicationMethod == "WMI"; public string? CapabilitiesRaw => _monitor.CapabilitiesRaw; public VcpCapabilities? VcpCapabilitiesInfo => _monitor.VcpCapabilitiesInfo; /// <summary> /// Gets the icon glyph based on communication method /// WMI monitors (laptop internal displays) use laptop icon, others use external monitor icon /// </summary> public string MonitorIconGlyph => _monitor.CommunicationMethod?.Contains("WMI", StringComparison.OrdinalIgnoreCase) == true ? AppConstants.UI.InternalMonitorGlyph // Laptop icon for WMI : AppConstants.UI.ExternalMonitorGlyph; // External monitor icon for DDC/CI and others // Advanced control display logic public bool HasAdvancedControls => ShowContrast || ShowVolume; /// <summary> /// Gets a value indicating whether this monitor supports contrast control via VCP 0x12 /// </summary> public bool SupportsContrast => _monitor.SupportsContrast; public bool SupportsBrightness => _monitor.SupportsBrightness; /// <summary> /// Gets a value indicating whether discovery read a trustworthy current brightness. /// </summary> public bool HasValidBrightnessReading => _monitor.ReadValues.HasFlag(MonitorReadFlags.Brightness); /// <summary> /// Gets a value indicating whether this monitor's brightness is currently driven by linked /// mode rather than its own slider. True when the parent has link mode on, this monitor /// supports brightness, and the user has not excluded it. When true the per-card brightness /// row shows a disabled linked-mode hint — the "All Displays" master slider broadcasts the /// value instead. /// </summary> public bool IsBrightnessLinked => (_mainViewModel?.LinkedLevelsActive ?? false) && SupportsBrightness && !IsExcludedFromSync; /// <summary> /// Gets a value indicating whether the per-card brightness slider accepts input. Disabled both /// while the app is busy (<see cref="IsInteractionEnabled"/>) and while link mode owns this /// monitor's brightness (<see cref="IsBrightnessLinked"/>). Excluded monitors keep an active /// slider, so this is true for them whenever interaction is enabled. /// </summary> public bool IsBrightnessSliderEnabled => IsInteractionEnabled && !IsBrightnessLinked; public bool ShowBrightnessSlider => ShowBrightness && !IsBrightnessLinked; public bool ShowIncludedInSyncIcon => !IsExcludedFromSync; /// <summary> /// Gets or sets a value indicating whether this monitor is excluded from linked brightness. /// Backed by the parent's shared exclusion set (keyed by <see cref="Id"/>), so the state /// survives monitor-list rebuilds and is persisted to settings.json. An excluded monitor keeps /// its own independent brightness slider while link mode is on. /// </summary> public bool IsExcludedFromSync { get => _mainViewModel?.IsMonitorExcludedFromSync(Id) ?? false; set { if (IsExcludedFromSync != value) { _mainViewModel?.SetMonitorExcludedFromSync(Id, value); OnPropertyChanged(); OnPropertyChanged(nameof(IsBrightnessLinked)); OnPropertyChanged(nameof(IsBrightnessSliderEnabled)); OnPropertyChanged(nameof(ShowBrightnessSlider)); OnPropertyChanged(nameof(ShowIncludedInSyncIcon)); OnPropertyChanged(nameof(SyncToggleToolTip)); } } } /// <summary> /// Gets a value indicating whether the per-card exclude toggle is shown. Only relevant while /// link mode is on and the monitor supports brightness — otherwise exclusion has no effect. /// </summary> public bool ShowExcludeButton => (_mainViewModel?.LinkedLevelsActive ?? false) && SupportsBrightness; /// <summary> /// Gets the tooltip for the per-card linked-brightness toggle. The action reverses with the /// current exclusion state: linked monitors can be excluded, excluded monitors can be included. /// </summary> public string SyncToggleToolTip => ResourceLoaderInstance.ResourceLoader.GetString( IsExcludedFromSync ? "IncludeInSyncToggleToolTip" : "ExcludeFromSyncToggleToolTip"); /// <summary> /// Gets a value indicating whether this monitor supports volume control via VCP 0x62 /// </summary> public bool SupportsVolume => _monitor.SupportsVolume; /// <summary> /// Gets or sets a value indicating whether to show power state control in the More Button flyout. /// </summary> public bool ShowPowerState { get => _showPowerState && SupportsPowerState; set { if (_showPowerState != value) { _showPowerState = value; OnPropertyChanged(); OnMoreButtonPropertiesChanged(); } } } /// <summary> /// Gets a value indicating whether the More Button should be visible. /// Visible when at least one feature (InputSource or PowerState) is enabled. /// </summary> public bool ShowMoreButton => ShowInputSource || ShowPowerState; /// <summary> /// Gets a value indicating whether to show separator after Input Source section. /// Only shown when both InputSource and PowerState are visible. /// </summary> public bool ShowSeparatorAfterInputSource => ShowInputSource && ShowPowerState; /// <summary> /// Notifies property changes for More Button related properties. /// </summary> private void OnMoreButtonPropertiesChanged() { OnPropertyChanged(nameof(ShowMoreButton)); OnPropertyChanged(nameof(ShowSeparatorAfterInputSource)); } /// <summary> /// Gets the current rotation/orientation of the monitor (0=normal, 1=90°, 2=180°, 3=270°) /// </summary> public int CurrentRotation => _monitor.Orientation; /// <summary> /// Gets a value indicating whether the current rotation is 0° (normal/default). /// </summary> public bool IsRotation0 => CurrentRotation == 0; /// <summary> /// Gets a value indicating whether the current rotation is 90° (rotated right). /// </summary> public bool IsRotation1 => CurrentRotation == 1; /// <summary> /// Gets a value indicating whether the current rotation is 180° (inverted). /// </summary> public bool IsRotation2 => CurrentRotation == 2; /// <summary> /// Gets a value indicating whether the current rotation is 270° (rotated left). /// </summary> public bool IsRotation3 => CurrentRotation == 3; /// <summary> /// Gets or sets the selected rotation index for binding to a ComboBox. /// Maps directly to <see cref="CurrentRotation"/>: 0=Landscape, 1=Portrait, 2=Landscape (flipped), 3=Portrait (flipped). /// </summary> public int SelectedRotationIndex { get => CurrentRotation; set => _ = SetRotationAsync(value); } /// <summary> /// Set rotation/orientation for this monitor. /// Note: MonitorManager.SetRotationAsync will refresh all monitors' orientations after success, /// which triggers PropertyChanged through OnMonitorPropertyChanged - no manual notification needed here. /// </summary> /// <param name="orientation">Orientation: 0=normal, 1=90°, 2=180°, 3=270°</param> public async Task SetRotationAsync(int orientation) { // Validate orientation range (0=normal, 1=90°, 2=180°, 3=270°) if (orientation < 0 || orientation > 3) { return; } // If already at this orientation, do nothing if (CurrentRotation == orientation) { return; } try { var result = await _monitorManager.SetRotationAsync(Id, orientation); if (!result.IsSuccess) { Logger.LogWarning($"[{Id}] Failed to set rotation: {result.ErrorMessage}"); } } catch (Exception ex) { Logger.LogError($"[{Id}] Exception setting rotation: {ex.Message}"); } } public int Brightness { get => _brightness; set { if (_brightness != value) { _brightness = value; OnPropertyChanged(); ScheduleCommit(ref _brightnessCommitTimer, () => SetBrightnessAsync(_brightness)); } } } /// <summary> /// Update the brightness backing field and notify the UI without scheduling a per-VM commit /// or hardware write. Used by <c>MainViewModel.LinkedBrightness</c> to keep each linked /// monitor's stored brightness aligned with the master value, so it is correct if the /// monitor is later excluded or link mode is disabled. The master broadcast is the single /// source of hardware writes while link mode is on, so this path must not schedule its own /// debounced commit. /// </summary> public void UpdateBrightnessDisplay(int value) { value = Math.Clamp(value, 0, 100); if (_brightness != value) { _brightness = value; OnPropertyChanged(nameof(Brightness)); } } /// <summary> /// Gets color temperature VCP preset value (from VCP code 0x14). /// Read-only in flyout UI - controlled via Settings UI. /// Returns the raw VCP value (e.g., 0x05 for 6500K). /// </summary> public int ColorTemperature => _monitor.CurrentColorTemperature; /// <summary> /// Gets human-readable color temperature preset name (e.g., "6500K", "sRGB") /// Uses custom mappings if available; falls back to built-in names if not. /// </summary> public string ColorTemperaturePresetName { get { var name = Common.Utils.VcpNames.GetValueName(0x14, _monitor.CurrentColorTemperature, _mainViewModel?.CustomVcpMappings, _monitor.Id); return name != null ? $"{name} (0x{_monitor.CurrentColorTemperature:X2})" : $"0x{_monitor.CurrentColorTemperature:X2}"; } } /// <summary> /// Gets a value indicating whether this monitor supports color temperature via VCP 0x14 /// </summary> public bool SupportsColorTemperature => _monitor.SupportsColorTemperature; private List<ColorTemperatureItem>? _availableColorPresets; private bool _showColorTemperature; /// <summary> /// Gets or sets a value indicating whether to show color temperature switcher (controlled by Settings UI, default false). /// </summary> public bool ShowColorTemperature { get => _showColorTemperature && SupportsColorTemperature; set { if (_showColorTemperature != value) { _showColorTemperature = value; OnPropertyChanged(); } } } /// <summary> /// Gets available color temperature presets for this monitor /// </summary> public List<ColorTemperatureItem>? AvailableColorPresets { get { if (_availableColorPresets == null && SupportsColorTemperature) { RefreshAvailableColorPresets(); } return _availableColorPresets; } } /// <summary> /// Refresh the list of available color temperature presets based on monitor capabilities. /// Only values explicitly reported in the capabilities string are exposed — no MCCS standard fallback. /// </summary> private void RefreshAvailableColorPresets() { var vcpInfo = VcpCapabilitiesInfo; if (!SupportsColorTemperature || vcpInfo == null || !vcpInfo.SupportedVcpCodes.TryGetValue(0x14, out var colorTempInfo) || !colorTempInfo.HasDiscreteValues) { _availableColorPresets = null; OnPropertyChanged(nameof(AvailableColorPresets)); return; } _availableColorPresets = colorTempInfo.SupportedValues.Select(value => new ColorTemperatureItem { VcpValue = value, DisplayName = Common.Utils.VcpNames.GetValueName(0x14, value, _mainViewModel?.CustomVcpMappings, _monitor.Id) is string n ? $"{n} (0x{value:X2})" : $"0x{value:X2}", IsSelected = value == _monitor.CurrentColorTemperature, MonitorId = _monitor.Id, }).ToList(); OnPropertyChanged(nameof(AvailableColorPresets)); } /// <summary> /// Gets a value indicating whether this monitor supports input source switching via VCP 0x60 /// </summary> public bool SupportsInputSource => _monitor.SupportsInputSource; /// <summary> /// Gets current input source VCP value (from VCP code 0x60) /// </summary> public int CurrentInputSource => _monitor.CurrentInputSource; /// <summary> /// 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. /// </summary> public string CurrentInputSourceName => Common.Utils.VcpNames.GetValueName(0x60, _monitor.CurrentInputSource, _mainViewModel?.CustomVcpMappings, _monitor.Id) ?? $"Source 0x{_monitor.CurrentInputSource:X2}"; private List<InputSourceItem>? _availableInputSources; /// <summary> /// Gets available input sources for this monitor /// </summary> public List<InputSourceItem>? AvailableInputSources { get { if (_availableInputSources == null && SupportsInputSource) { RefreshAvailableInputSources(); } return _availableInputSources; } } /// <summary> /// Refresh the list of available input sources based on monitor capabilities /// </summary> private void RefreshAvailableInputSources() { var supportedSources = _monitor.SupportedInputSources; if (supportedSources == null || supportedSources.Count == 0) { _availableInputSources = null; return; } _availableInputSources = supportedSources.Select(value => new InputSourceItem { Value = value, 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(); OnPropertyChanged(nameof(AvailableInputSources)); } /// <summary> /// Refresh custom VCP name displays after settings change. /// Called when CustomVcpMappings is updated from Settings UI. /// </summary> 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)); } /// <summary> /// Raise <see cref="PropertyChanged"/> for <see cref="MouseWheelIncrement"/> so per-monitor /// sliders pick up a new value after the user changes it in Settings. Called from /// <c>MainViewModel.ApplySettingsFromUI</c>. /// </summary> public void RefreshMouseWheelIncrement() { OnPropertyChanged(nameof(MouseWheelIncrement)); } /// <summary> /// Set input source for this monitor /// </summary> public async Task SetInputSourceAsync(int inputSource) { if (!IsDiscreteValueSupported(0x60, inputSource)) { return; } try { var result = await _monitorManager.SetInputSourceAsync(Id, inputSource); if (result.IsSuccess) { OnPropertyChanged(nameof(CurrentInputSource)); OnPropertyChanged(nameof(CurrentInputSourceName)); RefreshAvailableInputSources(); } else { Logger.LogWarning($"[{Id}] Failed to set input source: {result.ErrorMessage}"); } } catch (Exception ex) { Logger.LogError($"[{Id}] Exception setting input source: {ex.Message}"); } } /// <summary> /// Command to set input source /// </summary> [RelayCommand] private async Task SetInputSource(int? source) { if (source.HasValue) { await SetInputSourceAsync(source.Value); } } /// <summary> /// Gets a value indicating whether this monitor supports power state control via VCP 0xD6 /// </summary> public bool SupportsPowerState => _monitor.SupportsPowerState; private List<PowerStateItem>? _availablePowerStates; /// <summary> /// Gets available power states for this monitor. /// The current power state is shown as selected based on the monitor's actual state. /// </summary> public List<PowerStateItem>? AvailablePowerStates { get { if (_availablePowerStates == null && SupportsPowerState) { RefreshAvailablePowerStates(); } return _availablePowerStates; } } /// <summary> /// Refresh the list of available power states based on monitor capabilities /// </summary> private void RefreshAvailablePowerStates() { var supportedStates = _monitor.SupportedPowerStates; if (supportedStates == null || supportedStates.Count == 0) { _availablePowerStates = null; return; } _availablePowerStates = supportedStates.Select(value => new PowerStateItem { Value = value, Name = Common.Utils.VcpNames.GetValueName(0xD6, value) ?? $"State 0x{value:X2}", IsSelected = value == _monitor.CurrentPowerState, MonitorId = _monitor.Id, }).ToList(); OnPropertyChanged(nameof(AvailablePowerStates)); } /// <summary> /// Set the monitor's power state via VCP 0xD6: On (0x01) wakes the display, /// Standby/Suspend/Off put it to sleep. /// </summary> public async Task SetPowerStateAsync(int powerState) { if (!IsDiscreteValueSupported(0xD6, powerState)) { return; } try { var result = await _monitorManager.SetPowerStateAsync(Id, powerState); if (result.IsSuccess) { // Update the model's power state and refresh UI _monitor.CurrentPowerState = powerState; RefreshAvailablePowerStates(); } else { Logger.LogWarning($"[{Id}] Failed to set power state: {result.ErrorMessage}"); } } catch (Exception ex) { Logger.LogError($"[{Id}] Exception setting power state: {ex.Message}"); } } public int Contrast { get => _contrast; set { if (_contrast != value) { _contrast = value; OnPropertyChanged(); ScheduleCommit(ref _contrastCommitTimer, () => SetContrastAsync(_contrast)); } } } public int Volume { get => _volume; set { if (_volume != value) { _volume = value; OnPropertyChanged(); ScheduleCommit(ref _volumeCommitTimer, () => SetVolumeAsync(_volume)); } } } [RelayCommand] private void SetBrightness(int? brightness) { if (brightness.HasValue) { Brightness = brightness.Value; } } [RelayCommand] private void SetContrast(int? contrast) { if (contrast.HasValue) { Contrast = contrast.Value; } } [RelayCommand] private void SetVolume(int? volume) { if (volume.HasValue) { Volume = volume.Value; } } private void OnMainViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e) { if (e.PropertyName == nameof(MainViewModel.IsInteractionEnabled)) { OnPropertyChanged(nameof(IsInteractionEnabled)); OnPropertyChanged(nameof(IsBrightnessSliderEnabled)); } else if (e.PropertyName == nameof(MainViewModel.HasMonitors)) { // Monitor count changed, update display name to show/hide number suffix OnPropertyChanged(nameof(DisplayName)); } else if (e.PropertyName == nameof(MainViewModel.LinkedLevelsActive)) { // Link mode toggled — refresh the linked hint, exclude button, and slider state. OnPropertyChanged(nameof(IsBrightnessLinked)); OnPropertyChanged(nameof(IsBrightnessSliderEnabled)); OnPropertyChanged(nameof(ShowBrightnessSlider)); OnPropertyChanged(nameof(ShowExcludeButton)); } } private void OnMonitorPropertyChanged(object? sender, PropertyChangedEventArgs e) { // Forward Orientation changes from underlying Monitor to ViewModel properties // This is important for mirror mode where MonitorManager.RefreshAllOrientations() // updates multiple monitors sharing the same GdiDeviceName if (e.PropertyName == nameof(Monitor.Orientation)) { OnPropertyChanged(nameof(CurrentRotation)); OnPropertyChanged(nameof(IsRotation0)); OnPropertyChanged(nameof(IsRotation1)); OnPropertyChanged(nameof(IsRotation2)); OnPropertyChanged(nameof(IsRotation3)); OnPropertyChanged(nameof(SelectedRotationIndex)); } } // Slider commit handlers — debounced via ScheduleCommit. The setters store the // value, raise PropertyChanged, and (re)start a per-metric DispatcherQueueTimer // that fires the hardware write once the user stops moving. The commit closure // reads the latest field at fire-time so intermediate values are coalesced. private void ScheduleCommit(ref DispatcherQueueTimer? timer, Func<Task> commit) { SliderCommitScheduler.Schedule(ref timer, DispatcherQueue.GetForCurrentThread(), commit); } // Rotation button handlers — one per orientation to avoid Tag string parsing public async void HandleRotation0Click(object sender, RoutedEventArgs e) => await CommitRotationClickAsync(0); public async void HandleRotation1Click(object sender, RoutedEventArgs e) => await CommitRotationClickAsync(1); public async void HandleRotation2Click(object sender, RoutedEventArgs e) => await CommitRotationClickAsync(2); public async void HandleRotation3Click(object sender, RoutedEventArgs e) => await CommitRotationClickAsync(3); private async Task CommitRotationClickAsync(int orientation) { if (CurrentRotation == orientation) { // Force-notify to restore the ToggleButton checked state // (ToggleButton auto-unchecks on click; OneWay binding only re-pushes on change) OnPropertyChanged(nameof(IsRotation0)); OnPropertyChanged(nameof(IsRotation1)); OnPropertyChanged(nameof(IsRotation2)); OnPropertyChanged(nameof(IsRotation3)); return; } await SetRotationAsync(orientation); } // ListView selection handlers public async void HandleColorTemperatureSelectionChanged(object sender, SelectionChangedEventArgs e) { if (sender is not ListView listView || listView.SelectedItem is not ColorTemperatureItem item) { return; } await SetColorTemperatureAsync(item.VcpValue); listView.SelectedItem = null; } public async void HandleInputSourceSelectionChanged(object sender, SelectionChangedEventArgs e) { if (sender is not ListView listView || listView.SelectedItem is not InputSourceItem item) { return; } await SetInputSourceAsync(item.Value); } public async void HandlePowerStateSelectionChanged(object sender, SelectionChangedEventArgs e) { if (sender is not ListView listView || listView.SelectedItem is not PowerStateItem item) { return; } // Send the selected state straight to the hardware. Selecting On (0x01) wakes a // sleeping monitor: DDC/CI stays reachable in Standby/Suspend/Off(DPM), so the // write turns the panel back on (Off(Hard)/0x05 may still need a physical wake). await SetPowerStateAsync(item.Value); } public void Dispose() { // Drop any pending debounced commits — if we're being disposed the monitor is // gone (unplug/refresh) or the app is shutting down, so a hardware write would // race a half-torn-down MonitorManager. _brightnessCommitTimer?.Stop(); _contrastCommitTimer?.Stop(); _volumeCommitTimer?.Stop(); // Unsubscribe from MainViewModel events if (_mainViewModel != null) { _mainViewModel.PropertyChanged -= OnMainViewModelPropertyChanged; } // Unsubscribe from underlying Monitor events _monitor.PropertyChanged -= OnMonitorPropertyChanged; GC.SuppressFinalize(this); } }