// 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;
///
/// ViewModel for individual monitor
///
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;
///
/// Apply brightness with hardware update and state persistence.
///
/// Brightness value (0-100)
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);
}
///
/// Apply contrast with hardware update and state persistence.
///
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);
}
///
/// Apply volume with hardware update and state persistence.
///
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;
}
///
/// Unified method to apply color temperature with hardware update and state persistence.
/// Always immediate (no debouncing for discrete preset values).
///
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}");
}
}
///
/// Generic method to apply a monitor property to hardware and persist state.
/// Consolidates common logic for brightness, contrast, and volume operations.
///
/// Name of the property being set (for logging and state persistence)
/// Value to apply
/// Async function to call on MonitorManager
private async Task ApplyPropertyToHardwareAsync(
string propertyName,
int value,
Func> 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;
///
/// Gets the shared per-mouse-wheel-notch step for this monitor's sliders, proxied from the
/// owning . Falls back to 5 if the owner is unavailable.
///
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;
///
/// Gets the monitor number from the underlying monitor model (Windows DISPLAY number)
///
public int MonitorNumber => _monitor.MonitorNumber;
///
/// Gets the GDI display source name used to match the Windows primary display.
///
public string GdiDeviceName => _monitor.GdiDeviceName;
///
/// Gets the display name - includes monitor number when multiple monitors exist.
/// Follows the same logic as Settings UI's MonitorInfo.DisplayName for consistency.
///
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;
///
/// Gets the icon glyph based on communication method
/// WMI monitors (laptop internal displays) use laptop icon, others use external monitor icon
///
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;
///
/// Gets a value indicating whether this monitor supports contrast control via VCP 0x12
///
public bool SupportsContrast => _monitor.SupportsContrast;
public bool SupportsBrightness => _monitor.SupportsBrightness;
///
/// Gets a value indicating whether discovery read a trustworthy current brightness.
///
public bool HasValidBrightnessReading
=> _monitor.ReadValues.HasFlag(MonitorReadFlags.Brightness);
///
/// 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.
///
public bool IsBrightnessLinked => (_mainViewModel?.LinkedLevelsActive ?? false) && SupportsBrightness && !IsExcludedFromSync;
///
/// Gets a value indicating whether the per-card brightness slider accepts input. Disabled both
/// while the app is busy () and while link mode owns this
/// monitor's brightness (). Excluded monitors keep an active
/// slider, so this is true for them whenever interaction is enabled.
///
public bool IsBrightnessSliderEnabled => IsInteractionEnabled && !IsBrightnessLinked;
public bool ShowBrightnessSlider => ShowBrightness && !IsBrightnessLinked;
public bool ShowIncludedInSyncIcon => !IsExcludedFromSync;
///
/// Gets or sets a value indicating whether this monitor is excluded from linked brightness.
/// Backed by the parent's shared exclusion set (keyed by ), 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.
///
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));
}
}
}
///
/// 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.
///
public bool ShowExcludeButton => (_mainViewModel?.LinkedLevelsActive ?? false) && SupportsBrightness;
///
/// 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.
///
public string SyncToggleToolTip => ResourceLoaderInstance.ResourceLoader.GetString(
IsExcludedFromSync ? "IncludeInSyncToggleToolTip" : "ExcludeFromSyncToggleToolTip");
///
/// Gets a value indicating whether this monitor supports volume control via VCP 0x62
///
public bool SupportsVolume => _monitor.SupportsVolume;
///
/// Gets or sets a value indicating whether to show power state control in the More Button flyout.
///
public bool ShowPowerState
{
get => _showPowerState && SupportsPowerState;
set
{
if (_showPowerState != value)
{
_showPowerState = value;
OnPropertyChanged();
OnMoreButtonPropertiesChanged();
}
}
}
///
/// Gets a value indicating whether the More Button should be visible.
/// Visible when at least one feature (InputSource or PowerState) is enabled.
///
public bool ShowMoreButton => ShowInputSource || ShowPowerState;
///
/// Gets a value indicating whether to show separator after Input Source section.
/// Only shown when both InputSource and PowerState are visible.
///
public bool ShowSeparatorAfterInputSource => ShowInputSource && ShowPowerState;
///
/// Notifies property changes for More Button related properties.
///
private void OnMoreButtonPropertiesChanged()
{
OnPropertyChanged(nameof(ShowMoreButton));
OnPropertyChanged(nameof(ShowSeparatorAfterInputSource));
}
///
/// Gets the current rotation/orientation of the monitor (0=normal, 1=90°, 2=180°, 3=270°)
///
public int CurrentRotation => _monitor.Orientation;
///
/// Gets a value indicating whether the current rotation is 0° (normal/default).
///
public bool IsRotation0 => CurrentRotation == 0;
///
/// Gets a value indicating whether the current rotation is 90° (rotated right).
///
public bool IsRotation1 => CurrentRotation == 1;
///
/// Gets a value indicating whether the current rotation is 180° (inverted).
///
public bool IsRotation2 => CurrentRotation == 2;
///
/// Gets a value indicating whether the current rotation is 270° (rotated left).
///
public bool IsRotation3 => CurrentRotation == 3;
///
/// Gets or sets the selected rotation index for binding to a ComboBox.
/// Maps directly to : 0=Landscape, 1=Portrait, 2=Landscape (flipped), 3=Portrait (flipped).
///
public int SelectedRotationIndex
{
get => CurrentRotation;
set => _ = SetRotationAsync(value);
}
///
/// 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.
///
/// Orientation: 0=normal, 1=90°, 2=180°, 3=270°
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));
}
}
}
///
/// Update the brightness backing field and notify the UI without scheduling a per-VM commit
/// or hardware write. Used by MainViewModel.LinkedBrightness 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.
///
public void UpdateBrightnessDisplay(int value)
{
value = Math.Clamp(value, 0, 100);
if (_brightness != value)
{
_brightness = value;
OnPropertyChanged(nameof(Brightness));
}
}
///
/// 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).
///
public int ColorTemperature => _monitor.CurrentColorTemperature;
///
/// 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
{
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}";
}
}
///
/// Gets a value indicating whether this monitor supports color temperature via VCP 0x14
///
public bool SupportsColorTemperature => _monitor.SupportsColorTemperature;
private List? _availableColorPresets;
private bool _showColorTemperature;
///
/// Gets or sets a value indicating whether to show color temperature switcher (controlled by Settings UI, default false).
///
public bool ShowColorTemperature
{
get => _showColorTemperature && SupportsColorTemperature;
set
{
if (_showColorTemperature != value)
{
_showColorTemperature = value;
OnPropertyChanged();
}
}
}
///
/// Gets available color temperature presets for this monitor
///
public List? AvailableColorPresets
{
get
{
if (_availableColorPresets == null && SupportsColorTemperature)
{
RefreshAvailableColorPresets();
}
return _availableColorPresets;
}
}
///
/// 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.
///
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));
}
///
/// Gets a value indicating whether this monitor supports input source switching via VCP 0x60
///
public bool SupportsInputSource => _monitor.SupportsInputSource;
///
/// Gets current input source VCP value (from VCP code 0x60)
///
public int CurrentInputSource => _monitor.CurrentInputSource;
///
/// 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 =>
Common.Utils.VcpNames.GetValueName(0x60, _monitor.CurrentInputSource, _mainViewModel?.CustomVcpMappings, _monitor.Id)
?? $"Source 0x{_monitor.CurrentInputSource:X2}";
private List? _availableInputSources;
///
/// Gets available input sources for this monitor
///
public List? AvailableInputSources
{
get
{
if (_availableInputSources == null && SupportsInputSource)
{
RefreshAvailableInputSources();
}
return _availableInputSources;
}
}
///
/// Refresh the list of available input sources based on monitor capabilities
///
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));
}
///
/// 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));
}
///
/// Raise for so per-monitor
/// sliders pick up a new value after the user changes it in Settings. Called from
/// MainViewModel.ApplySettingsFromUI.
///
public void RefreshMouseWheelIncrement()
{
OnPropertyChanged(nameof(MouseWheelIncrement));
}
///
/// Set input source for this monitor
///
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}");
}
}
///
/// Command to set input source
///
[RelayCommand]
private async Task SetInputSource(int? source)
{
if (source.HasValue)
{
await SetInputSourceAsync(source.Value);
}
}
///
/// Gets a value indicating whether this monitor supports power state control via VCP 0xD6
///
public bool SupportsPowerState => _monitor.SupportsPowerState;
private List? _availablePowerStates;
///
/// Gets available power states for this monitor.
/// The current power state is shown as selected based on the monitor's actual state.
///
public List? AvailablePowerStates
{
get
{
if (_availablePowerStates == null && SupportsPowerState)
{
RefreshAvailablePowerStates();
}
return _availablePowerStates;
}
}
///
/// Refresh the list of available power states based on monitor capabilities
///
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));
}
///
/// Set the monitor's power state via VCP 0xD6: On (0x01) wakes the display,
/// Standby/Suspend/Off put it to sleep.
///
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 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);
}
}