Files
PowerToys/src/modules/powerdisplay/PowerDisplay/ViewModels/MonitorViewModel.cs
moooyo 8f63402400 PowerDisplay: Adjust brightness by scrolling over the tray icon (#49446)
## Summary of the Pull Request

Scrolling the mouse wheel over the Power Display tray icon adjusts
brightness, without opening the flyout.

- New **Tray icon mouse wheel** setting: `Off` / `Primary display` /
`All displays`, defaulting to **`Off`**. It is scoped to the tray icon —
the flyout sliders accept wheel input regardless, as they always have.
The existing **Mouse wheel increment** setting supplies the per-notch
step.
- **Off by default.** The gesture consumes a wheel notch that would
otherwise reach the window under the pointer, and acting on it installs
a system-wide `WH_MOUSE_LL` hook. Neither is something an existing
installation should acquire silently on upgrade. With the setting `Off`
no hook is ever installed and no notch is ever consumed, so this PR
changes no existing behaviour until the user opts in: 1958 insertions, 2
deletions, and both deletions are refactors of lines this feature
reuses.
- **No feedback UI.** Brightness is self-evidencing — you scroll and the
screen changes — so the display itself is the feedback. The notification
icon is untouched: same tooltip, same text, same legacy
notification-icon protocol.

## PR Checklist

- [x] Closes: #49410
- [ ] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [x] **Tests:** Added/updated and all pass
- [x] **Localization:** All end-user-facing strings can be localized
- [x] **Dev docs:** Added/updated
- [ ] **New binaries:** Added on the required places
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: #xxx

No new binaries or projects — everything lands in existing assemblies.
Communication is unchecked because #49410 is still Needs-Triage.

## Detailed Description of the Pull Request / Additional comments

### Why a low-level hook

The Shell does not forward `WM_MOUSEWHEEL` to a notification icon's
callback window under any `NOTIFYICON_VERSION`, and a click-through
overlay placed over the icon cannot receive wheel input either.
`TrayIconMouseWheelListener` therefore installs a `WH_MOUSE_LL` hook —
but only transiently, and only when it will act on the result:

- Nothing is installed at all while the setting is `Off`, which is the
default.
- Installed in `EnsureHook()` when the UI thread confirms the pointer is
inside the rectangle from `Shell_NotifyIconGetRect` **and**
`CanAdjustBrightnessFromTrayWheel` says some monitor can accept a
brightness write.
- Removed in `DisarmCore()` as soon as either condition stops holding,
the pointer leaves the rectangle, or the mode changes.
- A notch is consumed (the hook proc returns non-zero) only while armed
and only for points inside the armed rectangle, so a wheel event Power
Display will not act on still reaches the window under the cursor.

The hook runs on a dedicated background thread with its own message
loop; the proc itself only enqueues a sample and posts a drain request.
Deltas are marshalled to the UI thread in batches, and
`WheelDeltaAccumulator` folds high-resolution deltas (precision wheels,
touchpads) into whole notches. Each sample carries the hover generation
it was captured under, so samples from a hover the UI thread has already
retired are discarded rather than applied late.

### Hover detection

The Shell sends `WM_MOUSEMOVE` to the icon's callback window while the
pointer is over it. `TrayIconService.HandleTrayMouseMove` resolves the
rectangle with `Shell_NotifyIconGetRect` and caches it for a second,
because that message repeats for every pixel of travel.

`TrayIconService` gains nothing else: no protocol change, no new hover
UI, no polling. The rest of the file — and `MainWindow.xaml` — is
untouched.

### Linked brightness

While linked brightness is on, a notch has to move the whole group, so
it goes through `MainViewModel.LinkedBrightness` rather than the
individual monitor setters. The new master value is taken from the
planner's value for the monitor the wheel named, **not** from the
current master. The master is positional only —
`SeedInitialLinkedBrightness` takes it from the lowest-numbered linked
monitor and never writes hardware, and every monitor-list rebuild
re-seeds it — so it can sit arbitrarily far from the monitor the wheel
is aimed at. Stepping it relative to itself would apply a wrong-sized or
wrong-signed change, and a master already clamped at 0/100 would swallow
the notch while writing nothing at all.

The setting description calls out that linked brightness widens the
scope, so `Primary display` is not literally a single display while it
is on.

### What is deliberately not here

An earlier revision of this PR showed the target and percentage in a
custom overlay as you scrolled. Doing that meant the standard Shell
tooltip would not do (it cannot be shown on demand), which meant an own
window, which meant suppressing the Shell tooltip so the two did not
collide, which meant `NOTIFYICON_VERSION_4`, which changed the callback
packing and made the app responsible for all hover text — including for
keyboard and touch users, who never reach a cursor-anchored overlay and
would have been left with no visible tooltip at all.

That chain was about half the diff, for a readout that adds little on
top of watching the screen change. It is gone. If a readout is wanted
later it can be argued on its own merits, separately from this feature.

The same revision also gated the flyout sliders on this setting. That
bundled two unrelated things behind one switch — turning off tray
scrolling would also have stopped the contrast and volume sliders
responding to the wheel — so the setting is now scoped to the tray icon
and named accordingly.

An earlier revision also routed the tray **Exit** action through
`Shutdown()`. That fixes a pre-existing teardown leak which has nothing
to do with this feature, so it now lives in #49580 and is out of scope
here. This branch does not depend on it: the hook thread is a background
thread and the process is ending either way.

## Validation Steps Performed

- Unit tests: `PowerDisplay.Lib.UnitTests` 215 passed,
`Settings.UI.UnitTests` 165 passed.
- Builds: `PowerDisplay` and Settings UI, x64 Debug, no warnings.
- Automated coverage is in `PowerDisplay.Lib.UnitTests`: target
selection per mode, wheel accumulation including negative deltas,
partial notches and direction reversal, half-open rectangle containment,
and settings serialization and round-trip for the new mode, including
that a settings file predating the feature loads as `Off`.
`Settings.UI.UnitTests` covers the view-model index mapping and pins the
enum values to the ComboBox item order.
- The Win32 glue in `TrayIconService` and `TrayIconMouseWheelListener`
is not unit tested.

Manual passes performed: scrolling over the icon in both modes, the icon
parked in the notification overflow, high-resolution wheel input,
brightness boundaries, live monitor refresh while hovering, tray icon
hidden and re-enabled, Explorer restart, the context menu and
left-click, `Off` stopping tray scrolling while the flyout sliders keep
working, and confirming a notch that Power Display will not act on still
reaches the window under the cursor.

Not verified, needing hardware this branch has not been run on:

- Multiple taskbars, where the tray icon is on a secondary display and
`Primary display` mode adjusts a monitor the user may not be looking at.
- Mixed-DPI setups, for the `Shell_NotifyIconGetRect` rectangle and the
hook's physical-pixel hit test.

---------

Co-authored-by: Yu Leng <yuleng@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot-Session: 5d7f36fe-d175-4aa9-a3c7-b370d952d1d3
2026-07-31 16:21:08 +08:00

1005 lines
36 KiB
C#

// 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);
}
}