mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-07-10 12:33:27 +02:00
<!-- 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>
391 lines
13 KiB
C#
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();
|
|
}
|
|
}
|