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>
This commit is contained in:
moooyo
2026-02-03 13:53:25 +08:00
committed by GitHub
parent b3e7c9d227
commit 18efa0559c
181 changed files with 20738 additions and 165 deletions

View File

@@ -8,7 +8,6 @@ using System.Collections.ObjectModel;
using System.IO.Abstractions;
using System.Linq;
using System.Threading.Tasks;
using System.Windows.Threading;
using CommunityToolkit.WinUI.Controls;
using global::PowerToys.GPOWrapper;
using ManagedCommon;
@@ -21,6 +20,7 @@ using Microsoft.PowerToys.Settings.UI.Library.Interfaces;
using Microsoft.PowerToys.Settings.UI.Library.Utilities;
using Microsoft.PowerToys.Settings.UI.Services;
using Microsoft.PowerToys.Settings.UI.Views;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Settings.UI.Library;
@@ -33,7 +33,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
protected override string ModuleName => "Dashboard";
private Dispatcher dispatcher;
private DispatcherQueue dispatcher;
public Func<string, int> SendConfigMSG { get; }
@@ -107,7 +107,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
public DashboardViewModel(ISettingsRepository<GeneralSettings> settingsRepository, Func<string, int> ipcMSGCallBackFunc)
{
dispatcher = Dispatcher.CurrentDispatcher;
dispatcher = DispatcherQueue.GetForCurrentThread();
_settingsRepository = settingsRepository;
generalSettingsConfig = settingsRepository.SettingsConfig;
@@ -132,7 +132,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
private void OnSettingsChanged(GeneralSettings newSettings)
{
dispatcher.BeginInvoke(() =>
dispatcher.TryEnqueue(() =>
{
generalSettingsConfig = newSettings;
@@ -149,7 +149,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
protected override void OnConflictsUpdated(object sender, AllHotkeyConflictsEventArgs e)
{
dispatcher.BeginInvoke(() =>
dispatcher.TryEnqueue(() =>
{
var allConflictData = e.Conflicts;
foreach (var inAppConflict in allConflictData.InAppConflicts)
@@ -199,7 +199,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
IsEnabled = gpo == GpoRuleConfigured.Enabled || (gpo != GpoRuleConfigured.Disabled && ModuleHelper.GetIsModuleEnabled(generalSettingsConfig, moduleType)),
IsLocked = gpo == GpoRuleConfigured.Enabled || gpo == GpoRuleConfigured.Disabled,
Icon = ModuleHelper.GetModuleTypeFluentIconName(moduleType),
IsNew = moduleType == ModuleType.CursorWrap,
IsNew = moduleType == ModuleType.CursorWrap || moduleType == ModuleType.PowerDisplay,
DashboardModuleItems = GetModuleItems(moduleType),
ClickCommand = new RelayCommand<object>(DashboardListItemClick),
};
@@ -258,7 +258,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
finally
{
// Use dispatcher to reset flag after UI updates complete
dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Background, () =>
dispatcher.TryEnqueue(DispatcherQueuePriority.Low, () =>
{
_isSorting = false;
});
@@ -459,6 +459,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
ModuleType.MouseJump => GetModuleItemsMouseJump(),
ModuleType.MousePointerCrosshairs => GetModuleItemsMousePointerCrosshairs(),
ModuleType.Peek => GetModuleItemsPeek(),
ModuleType.PowerDisplay => GetModuleItemsPowerDisplay(),
ModuleType.PowerLauncher => GetModuleItemsPowerLauncher(),
ModuleType.PowerAccent => GetModuleItemsPowerAccent(),
ModuleType.Workspaces => GetModuleItemsWorkspaces(),
@@ -738,6 +739,18 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
return new ObservableCollection<DashboardModuleItem>(list);
}
private ObservableCollection<DashboardModuleItem> GetModuleItemsPowerDisplay()
{
ISettingsRepository<PowerDisplaySettings> moduleSettingsRepository = SettingsRepository<PowerDisplaySettings>.GetInstance(SettingsUtils.Default);
var settings = moduleSettingsRepository.SettingsConfig;
var list = new List<DashboardModuleItem>
{
new DashboardModuleShortcutItem() { Label = resourceLoader.GetString("PowerDisplay_ToggleWindow"), Shortcut = settings.Properties.ActivationShortcut.GetKeysList() },
new DashboardModuleButtonItem() { ButtonTitle = resourceLoader.GetString("PowerDisplay_LaunchButtonControl/Header"), IsButtonDescriptionVisible = true, ButtonDescription = resourceLoader.GetString("PowerDisplay_LaunchButtonControl/Description"), ButtonGlyph = "ms-appx:///Assets/Settings/Icons/PowerDisplay.png", ButtonClickHandler = PowerDisplayLaunchClicked },
};
return new ObservableCollection<DashboardModuleItem>(list);
}
internal void SWVersionButtonClicked()
{
NavigationService.Navigate(typeof(GeneralPage));
@@ -775,6 +788,12 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
SendConfigMSG("{\"action\":{\"RegistryPreview\":{\"action_name\":\"" + actionName + "\", \"value\":\"\"}}}");
}
private void PowerDisplayLaunchClicked(object sender, RoutedEventArgs e)
{
var actionName = "Launch";
SendConfigMSG("{\"action\":{\"PowerDisplay\":{\"action_name\":\"" + actionName + "\", \"value\":\"\"}}}");
}
internal void DashboardListItemClick(object sender)
{
if (sender is ModuleType moduleType)

View File

@@ -2,10 +2,13 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
#nullable enable
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text.Json;
@@ -17,6 +20,8 @@ using Microsoft.PowerToys.Settings.UI.Library.Helpers;
using Microsoft.PowerToys.Settings.UI.Library.Interfaces;
using Microsoft.PowerToys.Settings.UI.SerializationContext;
using Newtonsoft.Json.Linq;
using PowerDisplay.Common.Models;
using PowerDisplay.Common.Services;
using PowerToys.GPOWrapper;
using Settings.UI.Library;
using Settings.UI.Library.Helpers;
@@ -33,14 +38,14 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
public ObservableCollection<SearchLocation> SearchLocations { get; } = new();
public LightSwitchViewModel(ISettingsRepository<GeneralSettings> settingsRepository, LightSwitchSettings initialSettings = null, Func<string, int> ipcMSGCallBackFunc = null)
public LightSwitchViewModel(ISettingsRepository<GeneralSettings> settingsRepository, LightSwitchSettings? initialSettings = null, Func<string, int>? ipcMSGCallBackFunc = null)
{
ArgumentNullException.ThrowIfNull(settingsRepository);
GeneralSettingsConfig = settingsRepository.SettingsConfig;
InitializeEnabledValue();
_moduleSettings = initialSettings ?? new LightSwitchSettings();
SendConfigMSG = ipcMSGCallBackFunc;
SendConfigMSG = ipcMSGCallBackFunc ?? (_ => 0);
ForceLightCommand = new RelayCommand(ForceLightNow);
ForceDarkCommand = new RelayCommand(ForceDarkNow);
@@ -53,7 +58,11 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
"FollowNightLight",
};
_toggleThemeHotkey = _moduleSettings.Properties.ToggleThemeHotkey.Value;
// Load PowerDisplay profiles
LoadPowerDisplayProfiles();
// Check if PowerDisplay is enabled
CheckPowerDisplayEnabled();
}
public override Dictionary<string, HotkeySettings[]> GetAllHotkeySettings()
@@ -98,6 +107,16 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
SendConfigMSG("{\"action\":{\"LightSwitch\":{\"action_name\":\"" + actionName + "\", \"value\":\"\"}}}");
}
private void SaveSettings()
{
SendConfigMSG(
string.Format(
CultureInfo.InvariantCulture,
"{{ \"powertoys\": {{ \"{0}\": {1} }} }}",
LightSwitchSettings.ModuleName,
JsonSerializer.Serialize(_moduleSettings, SourceGenerationContextContext.Default.LightSwitchSettings)));
}
public LightSwitchSettings ModuleSettings
{
get => _moduleSettings;
@@ -428,9 +447,9 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
}
}
private SearchLocation _selectedSearchLocation;
private SearchLocation? _selectedSearchLocation;
public SearchLocation SelectedCity
public SearchLocation? SelectedCity
{
get => _selectedSearchLocation;
set
@@ -440,7 +459,10 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
_selectedSearchLocation = value;
NotifyPropertyChanged();
UpdateSunTimes(_selectedSearchLocation.Latitude, _selectedSearchLocation.Longitude, _selectedSearchLocation.City);
if (_selectedSearchLocation != null)
{
UpdateSunTimes(_selectedSearchLocation.Latitude, _selectedSearchLocation.Longitude, _selectedSearchLocation.City);
}
}
}
}
@@ -554,12 +576,248 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
}
}
public void NotifyPropertyChanged([CallerMemberName] string propertyName = null)
public void NotifyPropertyChanged([CallerMemberName] string? propertyName = null)
{
Logger.LogInfo($"Changed the property {propertyName}");
OnPropertyChanged(propertyName);
}
// PowerDisplay Integration Properties and Methods
public ObservableCollection<PowerDisplayProfile> AvailableProfiles
{
get => _availableProfiles;
set
{
if (_availableProfiles != value)
{
_availableProfiles = value;
NotifyPropertyChanged();
}
}
}
public bool IsPowerDisplayEnabled
{
get => _isPowerDisplayEnabled;
set
{
if (_isPowerDisplayEnabled != value)
{
_isPowerDisplayEnabled = value;
NotifyPropertyChanged();
NotifyPropertyChanged(nameof(ShowPowerDisplayDisabledWarning));
}
}
}
public bool ShowPowerDisplayDisabledWarning => !IsPowerDisplayEnabled;
public bool EnableDarkModeProfile
{
get => ModuleSettings.Properties.EnableDarkModeProfile.Value;
set
{
if (ModuleSettings.Properties.EnableDarkModeProfile.Value != value)
{
ModuleSettings.Properties.EnableDarkModeProfile.Value = value;
NotifyPropertyChanged();
NotifyPropertyChanged(nameof(ShowPowerDisplayDisabledWarning));
SaveSettings();
}
}
}
public bool EnableLightModeProfile
{
get => ModuleSettings.Properties.EnableLightModeProfile.Value;
set
{
if (ModuleSettings.Properties.EnableLightModeProfile.Value != value)
{
ModuleSettings.Properties.EnableLightModeProfile.Value = value;
NotifyPropertyChanged();
NotifyPropertyChanged(nameof(ShowPowerDisplayDisabledWarning));
SaveSettings();
}
}
}
public PowerDisplayProfile? SelectedDarkModeProfile
{
get => _selectedDarkModeProfile;
set
{
if (_selectedDarkModeProfile != value)
{
_selectedDarkModeProfile = value;
// Sync with the string property stored in settings
var newProfileName = value?.Name ?? string.Empty;
if (ModuleSettings.Properties.DarkModeProfile.Value != newProfileName)
{
ModuleSettings.Properties.DarkModeProfile.Value = newProfileName;
SaveSettings();
}
NotifyPropertyChanged();
}
}
}
public PowerDisplayProfile? SelectedLightModeProfile
{
get => _selectedLightModeProfile;
set
{
if (_selectedLightModeProfile != value)
{
_selectedLightModeProfile = value;
// Sync with the string property stored in settings
var newProfileName = value?.Name ?? string.Empty;
if (ModuleSettings.Properties.LightModeProfile.Value != newProfileName)
{
ModuleSettings.Properties.LightModeProfile.Value = newProfileName;
SaveSettings();
}
NotifyPropertyChanged();
}
}
}
// Legacy string properties for backwards compatibility with settings persistence
public string DarkModeProfile
{
get => ModuleSettings.Properties.DarkModeProfile.Value;
set
{
if (ModuleSettings.Properties.DarkModeProfile.Value != value)
{
ModuleSettings.Properties.DarkModeProfile.Value = value;
// Sync with the object property
UpdateSelectedProfileFromName(value, isDarkMode: true);
NotifyPropertyChanged();
}
}
}
public string LightModeProfile
{
get => ModuleSettings.Properties.LightModeProfile.Value;
set
{
if (ModuleSettings.Properties.LightModeProfile.Value != value)
{
ModuleSettings.Properties.LightModeProfile.Value = value;
// Sync with the object property
UpdateSelectedProfileFromName(value, isDarkMode: false);
NotifyPropertyChanged();
}
}
}
private void LoadPowerDisplayProfiles()
{
try
{
var profilesData = ProfileService.LoadProfiles();
AvailableProfiles.Clear();
foreach (var profile in profilesData.Profiles)
{
AvailableProfiles.Add(profile);
}
Logger.LogInfo($"Loaded {profilesData.Profiles.Count} PowerDisplay profiles");
// Sync selected profiles from settings
UpdateSelectedProfileFromName(ModuleSettings.Properties.DarkModeProfile.Value, isDarkMode: true);
UpdateSelectedProfileFromName(ModuleSettings.Properties.LightModeProfile.Value, isDarkMode: false);
}
catch (Exception ex)
{
Logger.LogError($"Failed to load PowerDisplay profiles: {ex.Message}");
AvailableProfiles.Clear();
}
}
/// <summary>
/// Helper method to sync the selected profile object from the profile name stored in settings.
/// If the configured profile no longer exists, clears the selection and updates settings.
/// </summary>
private void UpdateSelectedProfileFromName(string profileName, bool isDarkMode)
{
PowerDisplayProfile? matchingProfile = null;
if (!string.IsNullOrEmpty(profileName))
{
matchingProfile = AvailableProfiles.FirstOrDefault(p =>
p.Name.Equals(profileName, StringComparison.OrdinalIgnoreCase));
// If the configured profile no longer exists, clear it from settings
if (matchingProfile == null)
{
Logger.LogWarning($"Configured {(isDarkMode ? "dark" : "light")} mode profile '{profileName}' no longer exists, clearing selection");
if (isDarkMode)
{
ModuleSettings.Properties.DarkModeProfile.Value = string.Empty;
}
else
{
ModuleSettings.Properties.LightModeProfile.Value = string.Empty;
}
SaveSettings();
}
}
if (isDarkMode)
{
if (_selectedDarkModeProfile != matchingProfile)
{
_selectedDarkModeProfile = matchingProfile;
NotifyPropertyChanged(nameof(SelectedDarkModeProfile));
}
}
else
{
if (_selectedLightModeProfile != matchingProfile)
{
_selectedLightModeProfile = matchingProfile;
NotifyPropertyChanged(nameof(SelectedLightModeProfile));
}
}
}
private void CheckPowerDisplayEnabled()
{
try
{
var settingsUtils = SettingsUtils.Default;
var generalSettings = settingsUtils.GetSettingsOrDefault<GeneralSettings>(string.Empty);
IsPowerDisplayEnabled = generalSettings?.Enabled?.PowerDisplay ?? false;
Logger.LogInfo($"PowerDisplay enabled status: {IsPowerDisplayEnabled}");
}
catch (Exception ex)
{
Logger.LogError($"Failed to check PowerDisplay enabled status: {ex.Message}");
IsPowerDisplayEnabled = false;
}
}
public void RefreshPowerDisplayStatus()
{
CheckPowerDisplayEnabled();
NotifyPropertyChanged(nameof(ShowPowerDisplayDisabledWarning));
}
public void RefreshEnabledState()
{
OnPropertyChanged(nameof(IsEnabled));
@@ -576,6 +834,10 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
OnPropertyChanged(nameof(Latitude));
OnPropertyChanged(nameof(Longitude));
OnPropertyChanged(nameof(ScheduleMode));
OnPropertyChanged(nameof(EnableDarkModeProfile));
OnPropertyChanged(nameof(EnableLightModeProfile));
OnPropertyChanged(nameof(DarkModeProfile));
OnPropertyChanged(nameof(LightModeProfile));
}
private void UpdateSunTimes(double latitude, double longitude, string city = "n/a")
@@ -630,10 +892,15 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
private GpoRuleConfigured _enabledGpoRuleConfiguration;
private LightSwitchSettings _moduleSettings;
private bool _isEnabled;
private HotkeySettings _toggleThemeHotkey;
private TimeSpan? _sunriseTimeSpan;
private TimeSpan? _sunsetTimeSpan;
// PowerDisplay integration
private ObservableCollection<PowerDisplayProfile> _availableProfiles = new ObservableCollection<PowerDisplayProfile>();
private bool _isPowerDisplayEnabled;
private PowerDisplayProfile? _selectedDarkModeProfile;
private PowerDisplayProfile? _selectedLightModeProfile;
public ICommand ForceLightCommand { get; }
public ICommand ForceDarkCommand { get; }

View File

@@ -0,0 +1,178 @@
// 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.
#nullable enable
using System.ComponentModel;
using System.Runtime.CompilerServices;
using Microsoft.PowerToys.Settings.UI.Library;
namespace Microsoft.PowerToys.Settings.UI.ViewModels
{
/// <summary>
/// ViewModel for monitor selection in profile editor
/// </summary>
public class MonitorSelectionItem : INotifyPropertyChanged
{
private bool _isSelected;
private int _brightness = 100;
private int _contrast = 50;
private int _volume = 50;
private int _colorTemperature = 6500;
private bool _includeBrightness;
private bool _includeContrast;
private bool _includeVolume;
private bool _includeColorTemperature;
public required MonitorInfo Monitor { get; set; }
public bool SuppressAutoSelection { get; set; }
public bool IsSelected
{
get => _isSelected;
set
{
if (_isSelected != value)
{
_isSelected = value;
OnPropertyChanged();
}
}
}
public int Brightness
{
get => _brightness;
set
{
if (_brightness != value)
{
_brightness = value;
OnPropertyChanged();
if (!SuppressAutoSelection)
{
IncludeBrightness = true;
}
}
}
}
public int Contrast
{
get => _contrast;
set
{
if (_contrast != value)
{
_contrast = value;
OnPropertyChanged();
if (!SuppressAutoSelection)
{
IncludeContrast = true;
}
}
}
}
public int Volume
{
get => _volume;
set
{
if (_volume != value)
{
_volume = value;
OnPropertyChanged();
if (!SuppressAutoSelection)
{
IncludeVolume = true;
}
}
}
}
public int ColorTemperature
{
get => _colorTemperature;
set
{
if (_colorTemperature != value)
{
_colorTemperature = value;
OnPropertyChanged();
if (!SuppressAutoSelection)
{
IncludeColorTemperature = true;
}
}
}
}
public bool SupportsContrast => Monitor?.SupportsContrast ?? false;
public bool SupportsVolume => Monitor?.SupportsVolume ?? false;
public bool SupportsColorTemperature => Monitor?.SupportsColorTemperature ?? false;
public bool IncludeBrightness
{
get => _includeBrightness;
set
{
if (_includeBrightness != value)
{
_includeBrightness = value;
OnPropertyChanged();
}
}
}
public bool IncludeContrast
{
get => _includeContrast;
set
{
if (_includeContrast != value)
{
_includeContrast = value;
OnPropertyChanged();
}
}
}
public bool IncludeVolume
{
get => _includeVolume;
set
{
if (_includeVolume != value)
{
_includeVolume = value;
OnPropertyChanged();
}
}
}
public bool IncludeColorTemperature
{
get => _includeColorTemperature;
set
{
if (_includeColorTemperature != value)
{
_includeColorTemperature = value;
OnPropertyChanged();
}
}
}
public event PropertyChangedEventHandler? PropertyChanged;
protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}
}

View File

@@ -0,0 +1,671 @@
// 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.Globalization;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text.Json;
using global::PowerToys.GPOWrapper;
using ManagedCommon;
using Microsoft.PowerToys.Settings.UI.Helpers;
using Microsoft.PowerToys.Settings.UI.Library;
using Microsoft.PowerToys.Settings.UI.Library.Helpers;
using Microsoft.PowerToys.Settings.UI.Library.Interfaces;
using Microsoft.PowerToys.Settings.UI.Library.ViewModels.Commands;
using PowerDisplay.Common.Models;
using PowerDisplay.Common.Services;
using PowerDisplay.Common.Utils;
using PowerToys.Interop;
namespace Microsoft.PowerToys.Settings.UI.ViewModels
{
public partial class PowerDisplayViewModel : PageViewModelBase
{
protected override string ModuleName => PowerDisplaySettings.ModuleName;
private GeneralSettings GeneralSettingsConfig { get; set; }
private SettingsUtils SettingsUtils { get; set; }
public ButtonClickCommand LaunchEventHandler => new ButtonClickCommand(Launch);
public PowerDisplayViewModel(SettingsUtils settingsUtils, ISettingsRepository<GeneralSettings> settingsRepository, ISettingsRepository<PowerDisplaySettings> powerDisplaySettingsRepository, Func<string, int> ipcMSGCallBackFunc)
{
// To obtain the general settings configurations of PowerToys Settings.
ArgumentNullException.ThrowIfNull(settingsRepository);
SettingsUtils = settingsUtils;
GeneralSettingsConfig = settingsRepository.SettingsConfig;
_settings = powerDisplaySettingsRepository.SettingsConfig;
InitializeEnabledValue();
// Initialize monitors collection using property setter for proper subscription setup
var loadedMonitors = _settings.Properties.Monitors;
Logger.LogInfo($"[Constructor] Initializing with {loadedMonitors.Count} monitors from settings");
Monitors = new ObservableCollection<MonitorInfo>(loadedMonitors);
// set the callback functions value to handle outgoing IPC message.
SendConfigMSG = ipcMSGCallBackFunc;
// Load profiles
LoadProfiles();
// Listen for monitor refresh events from PowerDisplay.exe
NativeEventWaiter.WaitForEventLoop(
Constants.RefreshPowerDisplayMonitorsEvent(),
() =>
{
Logger.LogInfo("Received refresh monitors event from PowerDisplay.exe");
ReloadMonitorsFromSettings();
});
}
private GpoRuleConfigured _enabledGpoRuleConfiguration;
private bool _enabledStateIsGPOConfigured;
private void InitializeEnabledValue()
{
_enabledGpoRuleConfiguration = GPOWrapper.GetConfiguredPowerDisplayEnabledValue();
if (_enabledGpoRuleConfiguration == GpoRuleConfigured.Disabled || _enabledGpoRuleConfiguration == GpoRuleConfigured.Enabled)
{
// Get the enabled state from GPO
_enabledStateIsGPOConfigured = true;
_isEnabled = _enabledGpoRuleConfiguration == GpoRuleConfigured.Enabled;
}
else
{
_isEnabled = GeneralSettingsConfig.Enabled.PowerDisplay;
}
}
public bool IsEnabled
{
get => _isEnabled;
set
{
if (_enabledStateIsGPOConfigured)
{
// If it's GPO configured, shouldn't be able to change this state.
return;
}
if (_isEnabled != value)
{
_isEnabled = value;
OnPropertyChanged(nameof(IsEnabled));
GeneralSettingsConfig.Enabled.PowerDisplay = value;
OutGoingGeneralSettings outgoing = new OutGoingGeneralSettings(GeneralSettingsConfig);
SendConfigMSG(outgoing.ToString());
}
}
}
public bool IsEnabledGpoConfigured
{
get => _enabledStateIsGPOConfigured;
}
public bool RestoreSettingsOnStartup
{
get => _settings.Properties.RestoreSettingsOnStartup;
set => SetSettingsProperty(_settings.Properties.RestoreSettingsOnStartup, value, v => _settings.Properties.RestoreSettingsOnStartup = v);
}
public bool ShowSystemTrayIcon
{
get => _settings.Properties.ShowSystemTrayIcon;
set
{
if (SetSettingsProperty(_settings.Properties.ShowSystemTrayIcon, value, v => _settings.Properties.ShowSystemTrayIcon = v))
{
// Explicitly signal PowerDisplay to refresh tray icon
// This is needed because set_config() doesn't signal SettingsUpdatedEvent to avoid UI refresh issues
SignalSettingsUpdated();
Logger.LogInfo($"ShowSystemTrayIcon changed to {value}");
}
}
}
public bool ShowProfileSwitcher
{
get => _settings.Properties.ShowProfileSwitcher;
set
{
if (SetSettingsProperty(_settings.Properties.ShowProfileSwitcher, value, v => _settings.Properties.ShowProfileSwitcher = v))
{
SignalSettingsUpdated();
Logger.LogInfo($"ShowProfileSwitcher changed to {value}");
}
}
}
public bool ShowIdentifyMonitorsButton
{
get => _settings.Properties.ShowIdentifyMonitorsButton;
set
{
if (SetSettingsProperty(_settings.Properties.ShowIdentifyMonitorsButton, value, v => _settings.Properties.ShowIdentifyMonitorsButton = v))
{
SignalSettingsUpdated();
Logger.LogInfo($"ShowIdentifyMonitorsButton changed to {value}");
}
}
}
public HotkeySettings ActivationShortcut
{
get => _settings.Properties.ActivationShortcut;
set
{
if (SetSettingsProperty(_settings.Properties.ActivationShortcut, value, v => _settings.Properties.ActivationShortcut = v))
{
// Signal PowerDisplay.exe to re-register the hotkey
EventHelper.SignalEvent(Constants.HotkeyUpdatedPowerDisplayEvent());
Logger.LogInfo($"ActivationShortcut changed, signaled HotkeyUpdatedPowerDisplayEvent");
}
}
}
public override Dictionary<string, HotkeySettings[]> GetAllHotkeySettings()
{
var hotkeysDict = new Dictionary<string, HotkeySettings[]>
{
[ModuleName] = [ActivationShortcut],
};
return hotkeysDict;
}
/// <summary>
/// Gets or sets the delay in seconds before refreshing monitors after display changes.
/// </summary>
public int MonitorRefreshDelay
{
get => _settings.Properties.MonitorRefreshDelay;
set => SetSettingsProperty(_settings.Properties.MonitorRefreshDelay, value, v => _settings.Properties.MonitorRefreshDelay = v);
}
private readonly List<int> _monitorRefreshDelayOptions = new List<int> { 1, 2, 3, 5, 10 };
public List<int> MonitorRefreshDelayOptions => _monitorRefreshDelayOptions;
public ObservableCollection<MonitorInfo> Monitors
{
get => _monitors;
set
{
if (_monitors != null)
{
_monitors.CollectionChanged -= Monitors_CollectionChanged;
UnsubscribeFromItemPropertyChanged(_monitors);
}
_monitors = value;
if (_monitors != null)
{
_monitors.CollectionChanged += Monitors_CollectionChanged;
SubscribeToItemPropertyChanged(_monitors);
}
OnPropertyChanged(nameof(Monitors));
HasMonitors = _monitors?.Count > 0;
// Update TotalMonitorCount for dynamic DisplayName
UpdateTotalMonitorCount();
}
}
public bool HasMonitors
{
get => _hasMonitors;
set
{
if (_hasMonitors != value)
{
_hasMonitors = value;
OnPropertyChanged();
}
}
}
private void Monitors_CollectionChanged(object sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
{
SubscribeToItemPropertyChanged(e.NewItems?.Cast<MonitorInfo>());
UnsubscribeFromItemPropertyChanged(e.OldItems?.Cast<MonitorInfo>());
HasMonitors = _monitors.Count > 0;
_settings.Properties.Monitors = _monitors.ToList();
NotifySettingsChanged();
// Update TotalMonitorCount for dynamic DisplayName
UpdateTotalMonitorCount();
}
/// <summary>
/// Update TotalMonitorCount on all monitors for dynamic DisplayName formatting.
/// When multiple monitors exist, DisplayName shows "Name N" format.
/// </summary>
private void UpdateTotalMonitorCount()
{
if (_monitors == null)
{
return;
}
var count = _monitors.Count;
foreach (var monitor in _monitors)
{
monitor.TotalMonitorCount = count;
}
}
[System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA1816:Dispose methods should call SuppressFinalize", Justification = "Base class PageViewModelBase.Dispose() handles GC.SuppressFinalize")]
public override void Dispose()
{
// Unsubscribe from monitor property changes
UnsubscribeFromItemPropertyChanged(_monitors);
// Unsubscribe from collection changes
if (_monitors != null)
{
_monitors.CollectionChanged -= Monitors_CollectionChanged;
}
base.Dispose();
}
/// <summary>
/// Subscribe to PropertyChanged events for items in the collection
/// </summary>
private void SubscribeToItemPropertyChanged(IEnumerable<MonitorInfo> items)
{
if (items != null)
{
foreach (var item in items)
{
item.PropertyChanged += OnMonitorPropertyChanged;
}
}
}
/// <summary>
/// Unsubscribe from PropertyChanged events for items in the collection
/// </summary>
private void UnsubscribeFromItemPropertyChanged(IEnumerable<MonitorInfo> items)
{
if (items != null)
{
foreach (var item in items)
{
item.PropertyChanged -= OnMonitorPropertyChanged;
}
}
}
/// <summary>
/// Handle PropertyChanged events from MonitorInfo objects
/// </summary>
private void OnMonitorPropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e)
{
if (sender is MonitorInfo monitor)
{
Logger.LogDebug($"[PowerDisplayViewModel] Monitor {monitor.Name} property {e.PropertyName} changed");
}
// Update the settings object to keep it in sync
_settings.Properties.Monitors = _monitors.ToList();
// Save settings when any monitor property changes
NotifySettingsChanged();
// For feature visibility properties, explicitly signal PowerDisplay to refresh
// This is needed because set_config() doesn't signal SettingsUpdatedEvent to avoid UI refresh issues
if (e.PropertyName == nameof(MonitorInfo.EnableContrast) ||
e.PropertyName == nameof(MonitorInfo.EnableVolume) ||
e.PropertyName == nameof(MonitorInfo.EnableInputSource) ||
e.PropertyName == nameof(MonitorInfo.EnableRotation) ||
e.PropertyName == nameof(MonitorInfo.EnableColorTemperature) ||
e.PropertyName == nameof(MonitorInfo.EnablePowerState) ||
e.PropertyName == nameof(MonitorInfo.IsHidden))
{
SignalSettingsUpdated();
}
}
/// <summary>
/// Signal PowerDisplay.exe that settings have been updated and need to be applied
/// </summary>
private void SignalSettingsUpdated()
{
EventHelper.SignalEvent(Constants.SettingsUpdatedPowerDisplayEvent());
Logger.LogInfo("Signaled SettingsUpdatedPowerDisplayEvent for feature visibility change");
}
public void Launch()
{
var actionMessage = new PowerDisplayActionMessage
{
Action = new PowerDisplayActionMessage.ActionData
{
PowerDisplay = new PowerDisplayActionMessage.PowerDisplayAction
{
ActionName = "Launch",
Value = string.Empty,
},
},
};
SendConfigMSG(JsonSerializer.Serialize(actionMessage, SettingsSerializationContext.Default.PowerDisplayActionMessage));
}
/// <summary>
/// Reload monitor list from settings file (called when PowerDisplay.exe signals monitor changes)
/// </summary>
private void ReloadMonitorsFromSettings()
{
try
{
Logger.LogInfo("Reloading monitors from settings file");
// Read fresh settings from file
var updatedSettings = SettingsUtils.GetSettingsOrDefault<PowerDisplaySettings>(PowerDisplaySettings.ModuleName);
var updatedMonitors = updatedSettings.Properties.Monitors;
Logger.LogInfo($"[ReloadMonitors] Loaded {updatedMonitors.Count} monitors from settings");
// Update existing MonitorInfo objects instead of replacing the collection
// This preserves XAML x:Bind bindings which reference specific object instances
if (Monitors == null)
{
// First time initialization - create new collection
Monitors = new ObservableCollection<MonitorInfo>(updatedMonitors);
}
else
{
// Create a dictionary for quick lookup by Id
var updatedMonitorsDict = updatedMonitors.ToDictionary(m => m.Id, m => m);
// Update existing monitors or remove ones that no longer exist
for (int i = Monitors.Count - 1; i >= 0; i--)
{
var existingMonitor = Monitors[i];
if (updatedMonitorsDict.TryGetValue(existingMonitor.Id, out var updatedMonitor)
&& updatedMonitor != null)
{
// Monitor still exists - update its properties in place
Logger.LogInfo($"[ReloadMonitors] Updating existing monitor: {existingMonitor.Id}");
existingMonitor.UpdateFrom(updatedMonitor);
updatedMonitorsDict.Remove(existingMonitor.Id);
}
else
{
// Monitor no longer exists - remove from collection
Logger.LogInfo($"[ReloadMonitors] Removing monitor: {existingMonitor.Id}");
Monitors.RemoveAt(i);
}
}
// Add any new monitors that weren't in the existing collection
foreach (var newMonitor in updatedMonitorsDict.Values)
{
Logger.LogInfo($"[ReloadMonitors] Adding new monitor: {newMonitor.Id}");
Monitors.Add(newMonitor);
}
}
// Update internal settings reference
_settings.Properties.Monitors = updatedMonitors;
Logger.LogInfo($"Successfully reloaded {updatedMonitors.Count} monitors");
}
catch (Exception ex)
{
Logger.LogError($"Failed to reload monitors from settings: {ex.Message}");
}
}
private Func<string, int> SendConfigMSG { get; }
private bool _isEnabled;
private PowerDisplaySettings _settings;
private ObservableCollection<MonitorInfo> _monitors;
private bool _hasMonitors;
// Profile-related fields
private ObservableCollection<PowerDisplayProfile> _profiles = new ObservableCollection<PowerDisplayProfile>();
/// <summary>
/// Gets or sets collection of available profiles (for button display)
/// </summary>
public ObservableCollection<PowerDisplayProfile> Profiles
{
get => _profiles;
set
{
if (_profiles != value)
{
_profiles = value;
OnPropertyChanged();
}
}
}
public void RefreshEnabledState()
{
InitializeEnabledValue();
OnPropertyChanged(nameof(IsEnabled));
}
private bool SetSettingsProperty<T>(T currentValue, T newValue, Action<T> setter, [CallerMemberName] string propertyName = null)
{
if (EqualityComparer<T>.Default.Equals(currentValue, newValue))
{
return false;
}
setter(newValue);
OnPropertyChanged(propertyName);
NotifySettingsChanged();
return true;
}
/// <summary>
/// Load profiles from disk
/// </summary>
private void LoadProfiles()
{
try
{
var profilesData = ProfileService.LoadProfiles();
// Load profile objects (no Custom - it's not a profile anymore)
Profiles.Clear();
foreach (var profile in profilesData.Profiles)
{
Profiles.Add(profile);
}
Logger.LogInfo($"Loaded {Profiles.Count} profiles");
}
catch (Exception ex)
{
Logger.LogError($"Failed to load profiles: {ex.Message}");
Profiles.Clear();
}
}
/// <summary>
/// Apply a profile to monitors
/// </summary>
public void ApplyProfile(PowerDisplayProfile profile)
{
try
{
if (profile == null || !profile.IsValid())
{
Logger.LogWarning("Invalid profile");
return;
}
Logger.LogInfo($"Applying profile: {profile.Name}");
// Send custom action to trigger profile application
// The profile name is passed via Named Pipe IPC to PowerDisplay.exe
var actionMessage = new PowerDisplayActionMessage
{
Action = new PowerDisplayActionMessage.ActionData
{
PowerDisplay = new PowerDisplayActionMessage.PowerDisplayAction
{
ActionName = "ApplyProfile",
Value = profile.Name,
},
},
};
SendConfigMSG(JsonSerializer.Serialize(actionMessage, SettingsSerializationContext.Default.PowerDisplayActionMessage));
Logger.LogInfo($"Profile '{profile.Name}' applied successfully");
}
catch (Exception ex)
{
Logger.LogError($"Failed to apply profile: {ex.Message}");
}
}
/// <summary>
/// Create a new profile
/// </summary>
public void CreateProfile(PowerDisplayProfile profile)
{
try
{
if (profile == null || !profile.IsValid())
{
Logger.LogWarning("Invalid profile");
return;
}
Logger.LogInfo($"Creating profile: {profile.Name}");
var profilesData = ProfileService.LoadProfiles();
profilesData.SetProfile(profile);
ProfileService.SaveProfiles(profilesData);
// Reload profile list
LoadProfiles();
// Signal PowerDisplay to reload profiles
SignalSettingsUpdated();
Logger.LogInfo($"Profile '{profile.Name}' created successfully");
}
catch (Exception ex)
{
Logger.LogError($"Failed to create profile: {ex.Message}");
}
}
/// <summary>
/// Update an existing profile
/// </summary>
public void UpdateProfile(string oldName, PowerDisplayProfile newProfile)
{
try
{
if (newProfile == null || !newProfile.IsValid())
{
Logger.LogWarning("Invalid profile");
return;
}
Logger.LogInfo($"Updating profile: {oldName} -> {newProfile.Name}");
var profilesData = ProfileService.LoadProfiles();
// Remove old profile and add updated one
profilesData.RemoveProfile(oldName);
profilesData.SetProfile(newProfile);
ProfileService.SaveProfiles(profilesData);
// Reload profile list
LoadProfiles();
// Signal PowerDisplay to reload profiles
SignalSettingsUpdated();
Logger.LogInfo($"Profile updated to '{newProfile.Name}' successfully");
}
catch (Exception ex)
{
Logger.LogError($"Failed to update profile: {ex.Message}");
}
}
/// <summary>
/// Delete a profile
/// </summary>
public void DeleteProfile(string profileName)
{
try
{
if (string.IsNullOrEmpty(profileName))
{
return;
}
Logger.LogInfo($"Deleting profile: {profileName}");
var profilesData = ProfileService.LoadProfiles();
profilesData.RemoveProfile(profileName);
ProfileService.SaveProfiles(profilesData);
// Reload profile list
LoadProfiles();
// Signal PowerDisplay to reload profiles
SignalSettingsUpdated();
Logger.LogInfo($"Profile '{profileName}' deleted successfully");
}
catch (Exception ex)
{
Logger.LogError($"Failed to delete profile: {ex.Message}");
}
}
private void NotifySettingsChanged()
{
// Skip during initialization when SendConfigMSG is not yet set
if (SendConfigMSG == null)
{
return;
}
// Persist locally first so settings survive even if the module DLL isn't loaded yet.
SettingsUtils.SaveSettings(_settings.ToJsonString(), PowerDisplaySettings.ModuleName);
// Using InvariantCulture as this is an IPC message
// This message will be intercepted by the runner, which passes the serialized JSON to
// PowerDisplay Module Interface's set_config() method, which then applies it in-process.
SendConfigMSG(
string.Format(
CultureInfo.InvariantCulture,
"{{ \"powertoys\": {{ \"{0}\": {1} }} }}",
PowerDisplaySettings.ModuleName,
_settings.ToJsonString()));
}
}
}

View File

@@ -0,0 +1,140 @@
// 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.
#nullable enable
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Linq;
using System.Runtime.CompilerServices;
using Microsoft.PowerToys.Settings.UI.Library;
using PowerDisplay.Common.Models;
namespace Microsoft.PowerToys.Settings.UI.ViewModels
{
/// <summary>
/// ViewModel for Profile Editor Dialog
/// </summary>
public class ProfileEditorViewModel : INotifyPropertyChanged
{
private string _profileName = string.Empty;
private ObservableCollection<MonitorSelectionItem> _monitors;
public ProfileEditorViewModel(ObservableCollection<MonitorInfo> availableMonitors, string defaultName = "")
{
_profileName = defaultName;
_monitors = new ObservableCollection<MonitorSelectionItem>();
// Set TotalMonitorCount for DisplayName to show monitor numbers when multiple monitors exist
int totalCount = availableMonitors.Count;
foreach (var monitor in availableMonitors)
{
monitor.TotalMonitorCount = totalCount;
}
// Initialize monitor selection items
foreach (var monitor in availableMonitors)
{
var item = new MonitorSelectionItem
{
SuppressAutoSelection = true,
Monitor = monitor,
IsSelected = false,
Brightness = monitor.CurrentBrightness,
Contrast = 50, // Default value (MonitorInfo doesn't store contrast)
Volume = 50, // Default value (MonitorInfo doesn't store volume)
ColorTemperature = monitor.ColorTemperatureVcp,
};
item.SuppressAutoSelection = false;
// Subscribe to selection and checkbox changes
item.PropertyChanged += OnMonitorItemPropertyChanged;
_monitors.Add(item);
}
}
public string ProfileName
{
get => _profileName;
set
{
if (_profileName != value)
{
_profileName = value;
OnPropertyChanged();
OnPropertyChanged(nameof(CanSave));
}
}
}
public ObservableCollection<MonitorSelectionItem> Monitors
{
get => _monitors;
set
{
if (_monitors != value)
{
_monitors = value;
OnPropertyChanged();
}
}
}
public bool HasSelectedMonitors => _monitors?.Any(m => m.IsSelected) ?? false;
public bool HasValidSettings => _monitors != null &&
_monitors.Any(m => m.IsSelected) &&
_monitors.Where(m => m.IsSelected).All(m => m.IncludeBrightness || m.IncludeContrast || m.IncludeVolume || m.IncludeColorTemperature);
public bool CanSave => !string.IsNullOrWhiteSpace(_profileName) && HasSelectedMonitors && HasValidSettings;
public PowerDisplayProfile CreateProfile()
{
var settings = _monitors
.Where(m => m.IsSelected)
.Select(m => new ProfileMonitorSetting(
m.Monitor.Id, // Monitor Id (unique identifier)
m.IncludeBrightness ? (int?)m.Brightness : null,
m.IncludeColorTemperature && m.SupportsColorTemperature ? (int?)m.ColorTemperature : null,
m.IncludeContrast && m.SupportsContrast ? (int?)m.Contrast : null,
m.IncludeVolume && m.SupportsVolume ? (int?)m.Volume : null))
.ToList();
return new PowerDisplayProfile(_profileName, settings);
}
public event PropertyChangedEventHandler? PropertyChanged;
protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
/// <summary>
/// Handle property changes from monitor selection items.
/// Centralizes validation state updates to avoid duplication.
/// </summary>
private void OnMonitorItemPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
// Update selection-dependent properties
if (e.PropertyName == nameof(MonitorSelectionItem.IsSelected))
{
OnPropertyChanged(nameof(HasSelectedMonitors));
}
// Update validation state for relevant property changes
if (e.PropertyName == nameof(MonitorSelectionItem.IsSelected) ||
e.PropertyName == nameof(MonitorSelectionItem.IncludeBrightness) ||
e.PropertyName == nameof(MonitorSelectionItem.IncludeContrast) ||
e.PropertyName == nameof(MonitorSelectionItem.IncludeVolume) ||
e.PropertyName == nameof(MonitorSelectionItem.IncludeColorTemperature))
{
OnPropertyChanged(nameof(CanSave));
OnPropertyChanged(nameof(HasValidSettings));
}
}
}
}