Files
PowerToys/src/modules/powerdisplay/PowerDisplay/ViewModels/MainViewModel.cs

429 lines
13 KiB
C#
Raw Normal View History

Introduce new utility PowerDisplay to control your monitor settings (#42642) <!-- 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 Introduce a new PowerToys' module PowerDisplay to let user can control their monitor settings without touching monitor's button. Support feature list: Common: 1. Profiles support 2. Integration with LightSwitch (auto switch profile when theme change) 3. TrayIcon 4. Save and restore settings when startup 5. Shortcut 6. Rotation 7. GPO support 8. Auto re-discovery monitor when plugging and unplugging monitors. 9. Identify Monitors 10. Quick profile switch Especially for DDC/CI monitor: 1. Brightness 2. Contrast 3. Volume 4. Color temperature (preset profile) 5. Input source 6. Power State (poweroff) Design doc: https://github.com/microsoft/PowerToys/blob/yuleng/display/pr/3/doc/devdocs/modules/powerdisplay/design.md AOT compatibility: I designed this module for AOT from the start, so I'm pretty sure at least 95% of it is AOT compatible. But unfortunately, PowerToys still have a AOT blocker to block this module publish with AOT. Currently PowerToys will check the .net file version (file version not lib version) to avoid crash. So, all modules should reference Common.UI or add UseWPF to avoid overwrite the .net file with different version (which may cause crash). Todo: - [ ] BugBash - [ ] Icon - [ ] IdentifyWindow UI improvement Demo Main UI: <img width="546" height="671" alt="image" src="https://github.com/user-attachments/assets/b0ad9ac5-8000-4365-a192-ab8c2d66d4f1" /> Input Source: <img width="536" height="674" alt="image" src="https://github.com/user-attachments/assets/80f9ccd7-4f8c-4201-b177-cc86c5bcc9e3" /> Settings UI: <img width="1581" height="1191" alt="image" src="https://github.com/user-attachments/assets/6a82e4bb-8f96-4f28-abf9-d7c45e1c8ef7" /> <img width="1525" height="1146" alt="image" src="https://github.com/user-attachments/assets/aae81e65-08fd-453a-bf52-02a74f2fdea0" /> Closes: #42942 #42678 #41117 #38109 #35564 #34932 #28500 #1052 #18149 <!-- Please review the items on the PR checklist before submitting--> ## PR Checklist - [x] Closes: #1052 - [x] **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 <!-- 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 <yuleng@microsoft.com> Co-authored-by: Niels Laute <niels.laute@live.nl> Co-authored-by: moooyo <lengyuchn@gmail.com> Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 13:53:25 +08:00
// 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.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
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.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 : INotifyPropertyChanged, 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;
private ObservableCollection<MonitorViewModel> _monitors;
private ObservableCollection<PowerDisplayProfile> _profiles;
private bool _isScanning;
private bool _isInitialized;
private bool _isLoading;
/// <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;
// 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 ObservableCollection<MonitorViewModel> Monitors
{
get => _monitors;
set
{
_monitors = value;
OnPropertyChanged();
}
}
public ObservableCollection<PowerDisplayProfile> Profiles
{
get => _profiles;
set
{
_profiles = value;
OnPropertyChanged();
OnPropertyChanged(nameof(HasProfiles));
}
}
public bool HasProfiles => Profiles.Count > 0;
// UI display control properties - loaded from settings
private bool _showProfileSwitcher = true;
private bool _showIdentifyMonitorsButton = true;
/// <summary>
/// Gets a value indicating whether to show the profile switcher button.
/// Combines settings value with HasProfiles check.
/// </summary>
public bool ShowProfileSwitcherButton => _showProfileSwitcher && HasProfiles;
/// <summary>
/// Gets or sets a value indicating whether to show the profile switcher (from settings).
/// </summary>
public bool ShowProfileSwitcher
{
get => _showProfileSwitcher;
set
{
if (_showProfileSwitcher != value)
{
_showProfileSwitcher = value;
OnPropertyChanged();
OnPropertyChanged(nameof(ShowProfileSwitcherButton));
}
}
}
/// <summary>
/// Gets or sets a value indicating whether to show the identify monitors button.
/// </summary>
public bool ShowIdentifyMonitorsButton
{
get => _showIdentifyMonitorsButton;
set
{
if (_showIdentifyMonitorsButton != value)
{
_showIdentifyMonitorsButton = value;
OnPropertyChanged();
}
}
}
public bool IsScanning
{
get => _isScanning;
set
{
if (_isScanning != value)
{
_isScanning = value;
OnPropertyChanged();
// Dependent properties that change with IsScanning
OnPropertyChanged(nameof(HasMonitors));
OnPropertyChanged(nameof(ShowNoMonitorsMessage));
OnPropertyChanged(nameof(IsInteractionEnabled));
}
}
}
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
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 event PropertyChangedEventHandler? PropertyChanged;
protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
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;
}
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();
}
}