Files
PowerToys/src/modules/powerdisplay/PowerDisplay/ViewModels/MainViewModel.cs
moooyo 5520ae4cfa [PowerDisplay] Fix startup restore, volume init, and identify window lifecycle (#47051)
<!-- Enter a brief description/summary of your PR here. What does it
fix/what does it change/how was it tested (even manually, if necessary)?
-->
## Summary of the Pull Request
- **Volume initialization**: Read VCP 0x62 on monitor discovery so
`CurrentVolume` reflects actual hardware state instead of staying at the
50% default.
- **Brightness capability check**: Guard brightness init behind
`SupportsBrightness` flag, consistent with contrast/volume handling.
- **IdentifyWindow lifecycle**: Replace fire-and-forget `Task.Delay`
with `DispatcherQueueTimer` (UI-thread-safe, stoppable on dispose). Swap
`Activate`/`PositionOnDisplay` order to eliminate first-show flicker.
- **Startup restore fix**: Change `MonitorStateEntry` fields to `int?`
so unset values (`null`) aren't confused with zero — prevents writing
default 0% brightness/volume to hardware on startup.
- **Restore/profile apply refactor**: Push value validation down to
`Set*Async` (continuous → `Math.Clamp`, discrete → capabilities check),
extract unified `TryRestore` helper, remove redundant `IsValueInRange`
and `> 0` checks.
<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist

- [x] Closes: #xxx
<!-- - [ ] Closes: #yyy (add separate lines for additional resolved
issues) -->
- [ ] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [ ] **Tests:** Added/updated and all pass
- [ ] **Localization:** All end-user-facing strings can be localized
- [ ] **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

<!-- Provide a more detailed description of the PR, other things fixed,
or any additional comments/features here -->
## Detailed Description of the Pull Request / Additional comments

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed

---------

Co-authored-by: Yu Leng (from Dev Box) <yuleng@microsoft.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 08:41:14 +00:00

391 lines
13 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.Collections.ObjectModel;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using ManagedCommon;
using Microsoft.PowerToys.Settings.UI.Library;
using Microsoft.UI;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Windowing;
using PowerDisplay.Common.Drivers;
using PowerDisplay.Common.Drivers.DDC;
using PowerDisplay.Common.Models;
using PowerDisplay.Common.Services;
using PowerDisplay.Helpers;
using PowerDisplay.Models;
using PowerDisplay.PowerDisplayXAML;
namespace PowerDisplay.ViewModels;
/// <summary>
/// Main ViewModel for the PowerDisplay application.
/// Split into partial classes for better maintainability:
/// - MainViewModel.cs: Core properties, construction, and disposal
/// - MainViewModel.Monitors.cs: Monitor discovery and management
/// - MainViewModel.Settings.cs: Settings UI synchronization and profiles
/// </summary>
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicMethods)]
public partial class MainViewModel : ObservableObject, IDisposable
{
[LibraryImport("user32.dll", EntryPoint = "GetMonitorInfoW", StringMarshalling = StringMarshalling.Utf16)]
[return: MarshalAs(UnmanagedType.Bool)]
private static partial bool GetMonitorInfo(IntPtr hMonitor, ref MonitorInfoEx lpmi);
private readonly MonitorManager _monitorManager;
private readonly DispatcherQueue _dispatcherQueue;
private readonly CancellationTokenSource _cancellationTokenSource;
private readonly SettingsUtils _settingsUtils;
private readonly MonitorStateManager _stateManager;
private readonly DisplayChangeWatcher _displayChangeWatcher;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasMonitors))]
[NotifyPropertyChangedFor(nameof(ShowNoMonitorsMessage))]
[NotifyPropertyChangedFor(nameof(IsInteractionEnabled))]
public partial bool IsScanning { get; set; }
private bool _isInitialized;
private bool _isLoading;
[ObservableProperty]
public partial ObservableCollection<MonitorViewModel> Monitors { get; set; }
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasProfiles))]
[NotifyPropertyChangedFor(nameof(ShowProfileSwitcherButton))]
public partial ObservableCollection<PowerDisplayProfile> Profiles { get; set; }
/// <summary>
/// Event triggered when UI refresh is requested due to settings changes
/// </summary>
public event EventHandler? UIRefreshRequested;
/// <summary>
/// Event triggered when initial monitor discovery is completed.
/// Used by MainWindow to know when data is ready for display.
/// </summary>
public event EventHandler? InitializationCompleted;
public MainViewModel()
{
_dispatcherQueue = DispatcherQueue.GetForCurrentThread();
_cancellationTokenSource = new CancellationTokenSource();
Monitors = new ObservableCollection<MonitorViewModel>();
Profiles = new ObservableCollection<PowerDisplayProfile>();
IsScanning = true;
ShowProfileSwitcher = true;
ShowIdentifyMonitorsButton = true;
// Initialize settings utils
_settingsUtils = SettingsUtils.Default;
_stateManager = new MonitorStateManager();
// Initialize the monitor manager
_monitorManager = new MonitorManager();
// Load profiles for quick apply feature
LoadProfiles();
// Load UI display settings (profile switcher, identify button, color temp switcher)
LoadUIDisplaySettings();
// Initialize display change watcher for auto-refresh on monitor plug/unplug
// Use MonitorRefreshDelay from settings to allow hardware to stabilize after plug/unplug
var settings = _settingsUtils.GetSettingsOrDefault<PowerDisplaySettings>(PowerDisplaySettings.ModuleName);
int delaySeconds = Math.Clamp(settings?.Properties?.MonitorRefreshDelay ?? 5, 1, 30);
_displayChangeWatcher = new DisplayChangeWatcher(_dispatcherQueue, TimeSpan.FromSeconds(delaySeconds));
_displayChangeWatcher.DisplayChanged += OnDisplayChanged;
// Start initial discovery
_ = InitializeAsync(_cancellationTokenSource.Token);
}
public bool HasProfiles => Profiles.Count > 0;
// UI display control properties - loaded from settings
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowProfileSwitcherButton))]
public partial bool ShowProfileSwitcher { get; set; }
[ObservableProperty]
public partial bool ShowIdentifyMonitorsButton { get; set; }
/// <summary>
/// Gets a value indicating whether to show the profile switcher button.
/// Combines settings value with HasProfiles check.
/// </summary>
public bool ShowProfileSwitcherButton => ShowProfileSwitcher && HasProfiles;
// Custom VCP mappings - loaded from settings
private List<CustomVcpValueMapping> _customVcpMappings = new();
/// <summary>
/// Gets or sets the custom VCP value name mappings.
/// These mappings override the default VCP value names for color temperature and input source.
/// </summary>
public List<CustomVcpValueMapping> CustomVcpMappings
{
get => _customVcpMappings;
set
{
_customVcpMappings = value ?? new List<CustomVcpValueMapping>();
OnPropertyChanged();
}
}
public bool HasMonitors => !IsScanning && Monitors.Count > 0;
public bool ShowNoMonitorsMessage => !IsScanning && Monitors.Count == 0;
public bool IsInitialized
{
get => _isInitialized;
private set
{
_isInitialized = value;
OnPropertyChanged();
}
}
public bool IsLoading
{
get => _isLoading;
private set
{
_isLoading = value;
OnPropertyChanged();
OnPropertyChanged(nameof(IsInteractionEnabled));
}
}
/// <summary>
/// Gets a value indicating whether user interaction is enabled (not loading or scanning).
/// </summary>
public bool IsInteractionEnabled => !IsLoading && !IsScanning;
[RelayCommand]
private async Task RefreshAsync() => await RefreshMonitorsAsync();
[RelayCommand]
private unsafe void IdentifyMonitors()
{
try
{
// Get all display areas (virtual desktop regions)
var displayAreas = DisplayArea.FindAll();
// Get all monitor info from QueryDisplayConfig
var allDisplayInfo = DdcCiNative.GetAllMonitorDisplayInfo().Values.ToList();
// Build GDI name to MonitorNumber(s) mapping
// Note: In mirror mode, multiple monitors may share the same GdiDeviceName
var gdiToMonitorNumbers = allDisplayInfo
.Where(info => info.MonitorNumber > 0)
.GroupBy(info => info.GdiDeviceName, StringComparer.OrdinalIgnoreCase)
.ToDictionary(
g => g.Key,
g => g.Select(info => info.MonitorNumber).Distinct().OrderBy(n => n).ToList(),
StringComparer.OrdinalIgnoreCase);
// For each DisplayArea, get its HMONITOR, then get GDI device name to find MonitorNumber(s)
int windowsCreated = 0;
for (int i = 0; i < displayAreas.Count; i++)
{
var displayArea = displayAreas[i];
// Convert DisplayId to HMONITOR
var hMonitor = Win32Interop.GetMonitorFromDisplayId(displayArea.DisplayId);
if (hMonitor == IntPtr.Zero)
{
continue;
}
// Get GDI device name from HMONITOR
var monitorInfo = new MonitorInfoEx { CbSize = (uint)sizeof(MonitorInfoEx) };
if (!GetMonitorInfo(hMonitor, ref monitorInfo))
{
continue;
}
var gdiDeviceName = monitorInfo.GetDeviceName();
// Look up MonitorNumber(s) by GDI device name
if (!gdiToMonitorNumbers.TryGetValue(gdiDeviceName, out var monitorNumbers) || monitorNumbers.Count == 0)
{
continue;
}
// Format display text: single number for normal mode, "1|2" for mirror mode
var displayText = string.Join("|", monitorNumbers);
// Create and position identify window.
// Position before Activate so the window appears directly at the target
// location — avoiding a visible flicker from the default spawn position
// and skipping a WM_DPICHANGED round-trip when crossing DPI monitors.
var identifyWindow = new IdentifyWindow(displayText);
identifyWindow.PositionOnDisplay(displayArea);
identifyWindow.Activate();
windowsCreated++;
}
}
catch (Exception ex)
{
Logger.LogError($"Failed to identify monitors: {ex.Message}");
}
}
[RelayCommand]
private async Task ApplyProfile(PowerDisplayProfile? profile)
{
if (profile != null && profile.IsValid())
{
await ApplyProfileAsync(profile.MonitorSettings);
}
}
public void Dispose()
{
// Cancel all async operations first
_cancellationTokenSource?.Cancel();
// Dispose each resource independently to ensure all get cleaned up
try
{
_displayChangeWatcher?.Dispose();
}
catch
{
}
// Dispose monitor view models
foreach (var vm in Monitors)
{
try
{
vm.Dispose();
}
catch
{
}
}
try
{
_monitorManager?.Dispose();
}
catch
{
}
try
{
_stateManager?.Dispose();
}
catch
{
}
try
{
_cancellationTokenSource?.Dispose();
}
catch
{
}
try
{
Monitors.Clear();
}
catch
{
}
GC.SuppressFinalize(this);
}
/// <summary>
/// Load profiles from disk for quick apply feature
/// </summary>
private void LoadProfiles()
{
try
{
var profilesData = ProfileService.LoadProfiles();
Profiles.Clear();
foreach (var profile in profilesData.Profiles)
{
Profiles.Add(profile);
}
OnPropertyChanged(nameof(HasProfiles));
OnPropertyChanged(nameof(ShowProfileSwitcherButton));
}
catch (Exception ex)
{
Logger.LogError($"[Profile] Failed to load profiles: {ex.Message}");
}
}
/// <summary>
/// Load UI display settings from settings file
/// </summary>
private void LoadUIDisplaySettings()
{
try
{
var settings = _settingsUtils.GetSettingsOrDefault<PowerDisplaySettings>(PowerDisplaySettings.ModuleName);
ShowProfileSwitcher = settings.Properties.ShowProfileSwitcher;
ShowIdentifyMonitorsButton = settings.Properties.ShowIdentifyMonitorsButton;
// Load custom VCP mappings (now using shared type from PowerDisplay.Common.Models)
CustomVcpMappings = settings.Properties.CustomVcpMappings?.ToList() ?? new List<CustomVcpValueMapping>();
Logger.LogInfo($"[Settings] Loaded {CustomVcpMappings.Count} custom VCP mappings");
}
catch (Exception ex)
{
Logger.LogError($"[Settings] Failed to load UI display settings: {ex.Message}");
}
}
/// <summary>
/// Handles display configuration changes detected by the DisplayChangeWatcher.
/// The DisplayChangeWatcher already applies the configured delay (MonitorRefreshDelay)
/// to allow hardware to stabilize, so we can refresh immediately here.
/// </summary>
private async void OnDisplayChanged(object? sender, EventArgs e)
{
// Set scanning state to provide visual feedback
IsScanning = true;
// Perform refresh - DisplayChangeWatcher has already waited for hardware to stabilize
await RefreshMonitorsAsync(skipScanningCheck: true);
}
/// <summary>
/// Starts watching for display changes. Call after initialization is complete.
/// </summary>
public void StartDisplayWatching()
{
_displayChangeWatcher.Start();
}
/// <summary>
/// Stops watching for display changes.
/// </summary>
public void StopDisplayWatching()
{
_displayChangeWatcher.Stop();
}
}