Add GPO rule and profile management for PowerDisplay

Introduced a new GPO rule to manage PowerDisplay's enabled state via Group Policy, including updates to `GPOWrapper` and policy files (`PowerToys.admx` and `PowerToys.adml`).

Enhanced the PowerDisplay UI with profile management features, including quick apply, add, edit, and delete functionality. Updated `MainWindow.xaml` and `PowerDisplayPage.xaml` to support these changes, and added localized strings for improved accessibility.

Refactored `MainViewModel` to include a `Profiles` collection, `HasProfiles` property, and `ApplyProfileCommand`. Added methods to load profiles from disk and signal updates to PowerDisplay.

Improved error handling in `DdcCiController` and `WmiController` with input validation and WMI error classification. Optimized handle cleanup in `PhysicalMonitorHandleManager` with a more efficient algorithm.

Refactored `dllmain.cpp` to prevent duplicate PowerDisplay process launches. Updated initialization logic in `MainWindow.xaml.cs` to ensure proper ViewModel setup.

Localized strings for tooltips, warnings, and dialogs. Improved async behavior, logging, and UI accessibility.
This commit is contained in:
Yu Leng
2025-12-01 04:32:29 +08:00
parent fe36b62ec6
commit 0bbfc8015a
22 changed files with 417 additions and 89 deletions

View File

@@ -32,6 +32,10 @@ namespace winrt::PowerToys::GPOWrapper::implementation
{
return static_cast<GpoRuleConfigured>(powertoys_gpo::getConfiguredLightSwitchEnabledValue());
}
GpoRuleConfigured GPOWrapper::GetConfiguredPowerDisplayEnabledValue()
{
return static_cast<GpoRuleConfigured>(powertoys_gpo::getConfiguredPowerDisplayEnabledValue());
}
GpoRuleConfigured GPOWrapper::GetConfiguredFancyZonesEnabledValue()
{
return static_cast<GpoRuleConfigured>(powertoys_gpo::getConfiguredFancyZonesEnabledValue());

View File

@@ -14,6 +14,7 @@ namespace winrt::PowerToys::GPOWrapper::implementation
static GpoRuleConfigured GetConfiguredColorPickerEnabledValue();
static GpoRuleConfigured GetConfiguredCropAndLockEnabledValue();
static GpoRuleConfigured GetConfiguredLightSwitchEnabledValue();
static GpoRuleConfigured GetConfiguredPowerDisplayEnabledValue();
static GpoRuleConfigured GetConfiguredFancyZonesEnabledValue();
static GpoRuleConfigured GetConfiguredFileLocksmithEnabledValue();
static GpoRuleConfigured GetConfiguredSvgPreviewEnabledValue();

View File

@@ -18,6 +18,7 @@ namespace PowerToys
static GpoRuleConfigured GetConfiguredColorPickerEnabledValue();
static GpoRuleConfigured GetConfiguredCropAndLockEnabledValue();
static GpoRuleConfigured GetConfiguredLightSwitchEnabledValue();
static GpoRuleConfigured GetConfiguredPowerDisplayEnabledValue();
static GpoRuleConfigured GetConfiguredFancyZonesEnabledValue();
static GpoRuleConfigured GetConfiguredFileLocksmithEnabledValue();
static GpoRuleConfigured GetConfiguredSvgPreviewEnabledValue();

View File

@@ -31,6 +31,7 @@ namespace powertoys_gpo
const std::wstring POLICY_CONFIGURE_ENABLED_COLOR_PICKER = L"ConfigureEnabledUtilityColorPicker";
const std::wstring POLICY_CONFIGURE_ENABLED_CROP_AND_LOCK = L"ConfigureEnabledUtilityCropAndLock";
const std::wstring POLICY_CONFIGURE_ENABLED_LIGHT_SWITCH = L"ConfigureEnabledUtilityLightSwitch";
const std::wstring POLICY_CONFIGURE_ENABLED_POWER_DISPLAY = L"ConfigureEnabledUtilityPowerDisplay";
const std::wstring POLICY_CONFIGURE_ENABLED_FANCYZONES = L"ConfigureEnabledUtilityFancyZones";
const std::wstring POLICY_CONFIGURE_ENABLED_FILE_LOCKSMITH = L"ConfigureEnabledUtilityFileLocksmith";
const std::wstring POLICY_CONFIGURE_ENABLED_SVG_PREVIEW = L"ConfigureEnabledUtilityFileExplorerSVGPreview";
@@ -301,6 +302,11 @@ namespace powertoys_gpo
return getUtilityEnabledValue(POLICY_CONFIGURE_ENABLED_LIGHT_SWITCH);
}
inline gpo_rule_configured_t getConfiguredPowerDisplayEnabledValue()
{
return getUtilityEnabledValue(POLICY_CONFIGURE_ENABLED_POWER_DISPLAY);
}
inline gpo_rule_configured_t getConfiguredFancyZonesEnabledValue()
{
return getUtilityEnabledValue(POLICY_CONFIGURE_ENABLED_FANCYZONES);

View File

@@ -147,6 +147,16 @@
<decimal value="0" />
</disabledValue>
</policy>
<policy name="ConfigureEnabledUtilityPowerDisplay" class="Both" displayName="$(string.ConfigureEnabledUtilityPowerDisplay)" explainText="$(string.ConfigureEnabledUtilityDescription)" key="Software\Policies\PowerToys" valueName="ConfigureEnabledUtilityPowerDisplay">
<parentCategory ref="PowerToys" />
<supportedOn ref="SUPPORTED_POWERTOYS_0_95_0" />
<enabledValue>
<decimal value="1" />
</enabledValue>
<disabledValue>
<decimal value="0" />
</disabledValue>
</policy>
<policy name="ConfigureEnabledUtilityEnvironmentVariables" class="Both" displayName="$(string.ConfigureEnabledUtilityEnvironmentVariables)" explainText="$(string.ConfigureEnabledUtilityDescription)" key="Software\Policies\PowerToys" valueName="ConfigureEnabledUtilityEnvironmentVariables">
<parentCategory ref="PowerToys" />
<supportedOn ref="SUPPORTED_POWERTOYS_0_75_0" />

View File

@@ -246,6 +246,7 @@ If you don't configure this policy, the user will be able to control the setting
<string id="ConfigureEnabledUtilityCmdPal">CmdPal: Configure enabled state</string>
<string id="ConfigureEnabledUtilityCropAndLock">Crop And Lock: Configure enabled state</string>
<string id="ConfigureEnabledUtilityLightSwitch">Light Switch: Configure enabled state</string>
<string id="ConfigureEnabledUtilityPowerDisplay">PowerDisplay: Configure enabled state</string>
<string id="ConfigureEnabledUtilityEnvironmentVariables">Environment Variables: Configure enabled state</string>
<string id="ConfigureEnabledUtilityFancyZones">FancyZones: Configure enabled state</string>
<string id="ConfigureEnabledUtilityFileLocksmith">File Locksmith: Configure enabled state</string>

View File

@@ -52,6 +52,8 @@ namespace PowerDisplay.Common.Drivers.DDC
/// </summary>
public async Task<bool> CanControlMonitorAsync(Monitor monitor, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(monitor);
return await Task.Run(
() =>
{
@@ -73,6 +75,8 @@ namespace PowerDisplay.Common.Drivers.DDC
/// </summary>
public async Task<BrightnessInfo> GetBrightnessAsync(Monitor monitor, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(monitor);
return await Task.Run(
() =>
{
@@ -94,6 +98,7 @@ namespace PowerDisplay.Common.Drivers.DDC
/// </summary>
public async Task<MonitorOperationResult> SetBrightnessAsync(Monitor monitor, int brightness, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(monitor);
brightness = Math.Clamp(brightness, 0, 100);
return await Task.Run(
@@ -161,6 +166,8 @@ namespace PowerDisplay.Common.Drivers.DDC
/// </summary>
public async Task<BrightnessInfo> GetColorTemperatureAsync(Monitor monitor, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(monitor);
return await Task.Run(
() =>
{
@@ -192,6 +199,8 @@ namespace PowerDisplay.Common.Drivers.DDC
/// <param name="cancellationToken">Cancellation token</param>
public async Task<MonitorOperationResult> SetColorTemperatureAsync(Monitor monitor, int colorTemperature, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(monitor);
return await Task.Run(
() =>
{
@@ -242,6 +251,8 @@ namespace PowerDisplay.Common.Drivers.DDC
/// </summary>
public async Task<BrightnessInfo> GetInputSourceAsync(Monitor monitor, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(monitor);
return await Task.Run(
() =>
{
@@ -273,8 +284,10 @@ namespace PowerDisplay.Common.Drivers.DDC
/// <param name="cancellationToken">Cancellation token</param>
public async Task<MonitorOperationResult> SetInputSourceAsync(Monitor monitor, int inputSource, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(monitor);
return await Task.Run(
() =>
async () =>
{
if (monitor.Handle == IntPtr.Zero)
{
@@ -303,7 +316,7 @@ namespace PowerDisplay.Common.Drivers.DDC
Logger.LogInfo($"[{monitor.Id}] Set input source to {sourceName} via 0x60");
// Verify the change by reading back the value after a short delay
System.Threading.Thread.Sleep(100);
await Task.Delay(100, cancellationToken).ConfigureAwait(false);
if (DdcCiNative.TryGetVCPFeature(monitor.Handle, VcpCodeInputSource, out uint verifyValue, out uint _))
{
var verifyName = VcpValueNames.GetFormattedName(0x60, (int)verifyValue);
@@ -346,6 +359,8 @@ namespace PowerDisplay.Common.Drivers.DDC
/// </summary>
public async Task<string> GetCapabilitiesStringAsync(Monitor monitor, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(monitor);
// Check if capabilities are already cached
if (!string.IsNullOrEmpty(monitor.CapabilitiesRaw))
{

View File

@@ -95,6 +95,7 @@ namespace PowerDisplay.Common.Drivers.DDC
/// <summary>
/// Clean up handles that are no longer in use.
/// Called within ExecuteWithLock context with the internal dictionary.
/// Optimized to O(n) using HashSet lookup instead of O(n*m) nested loops.
/// </summary>
private void CleanupUnusedHandles(Dictionary<string, IntPtr> currentHandles, Dictionary<string, IntPtr> newHandles)
{
@@ -103,25 +104,16 @@ namespace PowerDisplay.Common.Drivers.DDC
return;
}
// Build HashSet of handles that will be reused (O(m))
var reusedHandles = new HashSet<IntPtr>(newHandles.Values);
// Find handles to destroy: in old map but not reused (O(n) with O(1) lookup)
var handlesToDestroy = new List<IntPtr>();
// Find handles that are in old map but not being reused
foreach (var oldMapping in currentHandles)
foreach (var oldHandle in currentHandles.Values)
{
bool found = false;
foreach (var newMapping in newHandles)
if (oldHandle != IntPtr.Zero && !reusedHandles.Contains(oldHandle))
{
// If the same handle is being reused, don't destroy it
if (oldMapping.Value == newMapping.Value)
{
found = true;
break;
}
}
if (!found && oldMapping.Value != IntPtr.Zero)
{
handlesToDestroy.Add(oldMapping.Value);
handlesToDestroy.Add(oldHandle);
}
}

View File

@@ -27,8 +27,41 @@ namespace PowerDisplay.Common.Drivers.WMI
private const string BrightnessMethodClass = "WmiMonitorBrightnessMethods";
private const string MonitorIdClass = "WmiMonitorID";
// Common WMI error codes for classification
private const int WbemENotFound = unchecked((int)0x80041002);
private const int WbemEAccessDenied = unchecked((int)0x80041003);
private const int WbemEProviderFailure = unchecked((int)0x80041004);
private const int WbemEInvalidQuery = unchecked((int)0x80041017);
private const int WmiFeatureNotSupported = 0x1068;
private bool _disposed;
/// <summary>
/// Classifies WMI exceptions into user-friendly error messages.
/// </summary>
private static MonitorOperationResult ClassifyWmiError(WmiException ex, string operation)
{
var hresult = ex.HResult;
return hresult switch
{
WbemENotFound => MonitorOperationResult.Failure($"WMI class not found during {operation}. This feature may not be supported on your system.", hresult),
WbemEAccessDenied => MonitorOperationResult.Failure($"Access denied during {operation}. Administrator privileges may be required.", hresult),
WbemEProviderFailure => MonitorOperationResult.Failure($"WMI provider failure during {operation}. The display driver may not support this feature.", hresult),
WbemEInvalidQuery => MonitorOperationResult.Failure($"Invalid WMI query during {operation}. This is likely a bug.", hresult),
WmiFeatureNotSupported => MonitorOperationResult.Failure($"WMI brightness control not supported on this system during {operation}.", hresult),
_ => MonitorOperationResult.Failure($"WMI error during {operation}: {ex.Message}", hresult),
};
}
/// <summary>
/// Determines if the WMI error is expected for systems without WMI brightness support.
/// </summary>
private static bool IsExpectedUnsupportedError(WmiException ex)
{
return ex.HResult == WmiFeatureNotSupported || ex.HResult == WbemENotFound;
}
public string Name => "WMI Monitor Controller (WmiLight)";
/// <summary>
@@ -36,6 +69,8 @@ namespace PowerDisplay.Common.Drivers.WMI
/// </summary>
public async Task<bool> CanControlMonitorAsync(Monitor monitor, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(monitor);
if (monitor.CommunicationMethod != "WMI")
{
return false;
@@ -65,6 +100,8 @@ namespace PowerDisplay.Common.Drivers.WMI
/// </summary>
public async Task<BrightnessInfo> GetBrightnessAsync(Monitor monitor, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(monitor);
return await Task.Run(
() =>
{
@@ -99,6 +136,8 @@ namespace PowerDisplay.Common.Drivers.WMI
/// </summary>
public async Task<MonitorOperationResult> SetBrightnessAsync(Monitor monitor, int brightness, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(monitor);
// Validate brightness range
brightness = Math.Clamp(brightness, 0, 100);
@@ -147,11 +186,11 @@ namespace PowerDisplay.Common.Drivers.WMI
}
catch (WmiException ex)
{
return MonitorOperationResult.Failure($"WMI error: {ex.Message}", ex.HResult);
return ClassifyWmiError(ex, "SetBrightness");
}
catch (Exception ex)
{
return MonitorOperationResult.Failure($"Unexpected error: {ex.Message}");
return MonitorOperationResult.Failure($"Unexpected error during SetBrightness: {ex.Message}");
}
},
cancellationToken);
@@ -309,15 +348,21 @@ namespace PowerDisplay.Common.Drivers.WMI
var results = connection.CreateQuery(query).ToList();
return results.Count > 0;
}
catch (WmiException ex) when (ex.HResult == 0x1068)
catch (WmiException ex) when (IsExpectedUnsupportedError(ex))
{
// Expected on systems without WMI brightness support (desktops, some laptops)
Logger.LogInfo("WMI brightness control not supported on this system (expected for desktops)");
return false;
}
catch (WmiException ex)
{
// Unexpected WMI error - log with details for debugging
Logger.LogWarning($"WMI availability check failed: {ex.Message} (HResult: 0x{ex.HResult:X})");
return false;
}
catch (Exception ex)
{
// Unexpected error during WMI check
// Unexpected non-WMI error
Logger.LogDebug($"WMI availability check failed: {ex.Message}");
return false;
}

View File

@@ -7,6 +7,7 @@
xmlns:local="using:PowerDisplay"
xmlns:toolkit="using:CommunityToolkit.WinUI.Controls"
xmlns:ui="using:CommunityToolkit.WinUI"
xmlns:models="using:PowerDisplay.Common.Models"
xmlns:vm="using:PowerDisplay.ViewModels"
xmlns:winuiex="using:WinUIEx"
MinWidth="0"
@@ -361,10 +362,19 @@
VerticalAlignment="Center"
Orientation="Horizontal"
Spacing="8">
<Button Content="{ui:FontIcon Glyph=&#xE748;, FontSize=16}" Style="{StaticResource SubtleButtonStyle}">
<Button
x:Name="ProfilesButton"
x:Uid="ProfilesTooltip"
Content="{ui:FontIcon Glyph=&#xE748;, FontSize=16}"
Style="{StaticResource SubtleButtonStyle}"
Visibility="{x:Bind ConvertBoolToVisibility(ViewModel.HasProfiles), Mode=OneWay}">
<Button.Flyout>
<Flyout>
<ListView SelectedIndex="2">
<Flyout x:Name="ProfilesFlyout">
<ListView
x:Name="ProfilesListView"
ItemsSource="{x:Bind ViewModel.Profiles, Mode=OneWay}"
SelectionChanged="ProfileListView_SelectionChanged"
SelectionMode="Single">
<ListView.Header>
<TextBlock
Margin="16,0,8,0"
@@ -372,9 +382,11 @@
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="Profiles" />
</ListView.Header>
<ListViewItem Content="Profile 1" />
<ListViewItem Content="Profile 2" />
<ListViewItem Content="Profile 3" />
<ListView.ItemTemplate>
<DataTemplate x:DataType="models:PowerDisplayProfile">
<TextBlock Padding="0,4" Text="{x:Bind Name}" />
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
</Flyout>
</Button.Flyout>

View File

@@ -35,12 +35,12 @@ namespace PowerDisplay
public sealed partial class MainWindow : WindowEx, IDisposable
{
private readonly ISettingsUtils _settingsUtils = new SettingsUtils();
private MainViewModel _viewModel = null!;
private AppWindow _appWindow = null!;
private MainViewModel? _viewModel;
private AppWindow? _appWindow;
private bool _isExiting;
// Expose ViewModel as property for x:Bind
public MainViewModel ViewModel => _viewModel;
public MainViewModel ViewModel => _viewModel ?? throw new InvalidOperationException("ViewModel not initialized");
// Conversion functions for x:Bind (AOT-compatible alternative to converters)
public Visibility ConvertBoolToVisibility(bool value) => value ? Visibility.Visible : Visibility.Collapsed;
@@ -49,20 +49,23 @@ namespace PowerDisplay
{
try
{
// 1. Create ViewModel BEFORE InitializeComponent to avoid x:Bind failures
// x:Bind evaluates during InitializeComponent, so ViewModel must exist first
_viewModel = new MainViewModel();
this.InitializeComponent();
// 1. Configure window immediately (synchronous, no data dependency)
// 2. Configure window immediately (synchronous, no data dependency)
ConfigureWindow();
// 2. Create ViewModel immediately (lightweight object, no scanning yet)
_viewModel = new MainViewModel();
// 3. Set up data context and update bindings
RootGrid.DataContext = _viewModel;
Bindings.Update();
// 3. Register event handlers
// 4. Register event handlers
RegisterEventHandlers();
// 4. Start background initialization (don't wait)
// 5. Start background initialization (don't wait)
_ = Task.Run(async () =>
{
try
@@ -93,10 +96,13 @@ namespace PowerDisplay
this.Closed += OnWindowClosed;
this.Activated += OnWindowActivated;
// ViewModel events
_viewModel.UIRefreshRequested += OnUIRefreshRequested;
_viewModel.Monitors.CollectionChanged += OnMonitorsCollectionChanged;
_viewModel.PropertyChanged += OnViewModelPropertyChanged;
// ViewModel events - _viewModel is guaranteed non-null here as this is called after initialization
if (_viewModel != null)
{
_viewModel.UIRefreshRequested += OnUIRefreshRequested;
_viewModel.Monitors.CollectionChanged += OnMonitorsCollectionChanged;
_viewModel.PropertyChanged += OnViewModelPropertyChanged;
}
}
private bool _hasInitialized;
@@ -123,7 +129,10 @@ namespace PowerDisplay
try
{
// Perform monitor scanning (which internally calls ReloadMonitorSettingsAsync)
await _viewModel.RefreshMonitorsAsync();
if (_viewModel != null)
{
await _viewModel.RefreshMonitorsAsync();
}
// Adjust window size after data is loaded (must run on UI thread)
DispatcherQueue.TryEnqueue(() => AdjustWindowSizeToContent());
@@ -157,7 +166,7 @@ namespace PowerDisplay
// Auto-hide window when it loses focus (deactivated)
if (args.WindowActivationState == WindowActivationState.Deactivated)
{
// HideWindow();
HideWindow();
}
}
@@ -805,6 +814,37 @@ namespace PowerDisplay
await monitorVm.SetRotationAsync(orientation);
}
/// <summary>
/// Profile selection changed handler - applies the selected profile
/// </summary>
private void ProfileListView_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (sender is not ListView listView)
{
return;
}
var selectedProfile = listView.SelectedItem as PowerDisplayProfile;
if (selectedProfile == null || !selectedProfile.IsValid())
{
return;
}
Logger.LogInfo($"[UI] ProfileListView_SelectionChanged: Applying profile '{selectedProfile.Name}'");
// Apply profile via ViewModel command
if (_viewModel?.ApplyProfileCommand?.CanExecute(selectedProfile) == true)
{
_viewModel.ApplyProfileCommand.Execute(selectedProfile);
}
// Close the flyout after selection
ProfilesFlyout?.Hide();
// Clear selection to allow reselecting the same profile
listView.SelectedItem = null;
}
public void Dispose()
{
_viewModel?.Dispose();

View File

@@ -53,6 +53,7 @@ namespace PowerDisplay
else
{
Logger.LogWarning("Another instance of PowerDisplay is running. Exiting.");
return;
}
}
}

View File

@@ -78,4 +78,10 @@
<data name="TrayMenu_Exit" xml:space="preserve">
<value>Exit</value>
</data>
<data name="ProfilesTooltip.ToolTipService.ToolTip" xml:space="preserve">
<value>Quick apply profiles</value>
</data>
<data name="IdentifyTooltip.ToolTipService.ToolTip" xml:space="preserve">
<value>Identify monitors</value>
</data>
</root>

View File

@@ -46,6 +46,9 @@ public partial class MainViewModel
var settings = _settingsUtils.GetSettingsOrDefault<PowerDisplaySettings>("PowerDisplay");
ApplyUIConfiguration(settings);
// Reload profiles in case they were added/updated/deleted in Settings UI
LoadProfiles();
Logger.LogInfo("[Settings] Settings update complete");
}
catch (Exception ex)

View File

@@ -15,6 +15,7 @@ using Microsoft.PowerToys.Settings.UI.Library;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Windowing;
using PowerDisplay.Commands;
using PowerDisplay.Common.Models;
using PowerDisplay.Common.Services;
using PowerDisplay.Core;
using PowerDisplay.Helpers;
@@ -40,6 +41,7 @@ public partial class MainViewModel : INotifyPropertyChanged, IDisposable
private readonly LightSwitchListener _lightSwitchListener;
private ObservableCollection<MonitorViewModel> _monitors;
private ObservableCollection<PowerDisplayProfile> _profiles;
private string _statusText;
private bool _isScanning;
private bool _isInitialized;
@@ -55,6 +57,7 @@ public partial class MainViewModel : INotifyPropertyChanged, IDisposable
_dispatcherQueue = DispatcherQueue.GetForCurrentThread();
_cancellationTokenSource = new CancellationTokenSource();
_monitors = new ObservableCollection<MonitorViewModel>();
_profiles = new ObservableCollection<PowerDisplayProfile>();
_statusText = "Initializing...";
_isScanning = true;
@@ -73,6 +76,9 @@ public partial class MainViewModel : INotifyPropertyChanged, IDisposable
_lightSwitchListener.ThemeChanged += OnLightSwitchThemeChanged;
_lightSwitchListener.Start();
// Load profiles for quick apply feature
LoadProfiles();
// Start initial discovery
_ = InitializeAsync();
}
@@ -87,6 +93,19 @@ public partial class MainViewModel : INotifyPropertyChanged, IDisposable
}
}
public ObservableCollection<PowerDisplayProfile> Profiles
{
get => _profiles;
set
{
_profiles = value;
OnPropertyChanged();
OnPropertyChanged(nameof(HasProfiles));
}
}
public bool HasProfiles => Profiles.Count > 0;
public string StatusText
{
get => _statusText;
@@ -196,6 +215,15 @@ public partial class MainViewModel : INotifyPropertyChanged, IDisposable
}
});
public ICommand ApplyProfileCommand => new RelayCommand<PowerDisplayProfile>(async profile =>
{
if (profile != null && profile.IsValid())
{
Logger.LogInfo($"[Profile] Applying profile '{profile.Name}' from quick apply");
await ApplyProfileAsync(profile.Name, profile.MonitorSettings);
}
});
public event PropertyChangedEventHandler? PropertyChanged;
protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null)
@@ -254,4 +282,27 @@ public partial class MainViewModel : INotifyPropertyChanged, IDisposable
Logger.LogDebug($"Error executing {name}: {ex.Message}");
}
}
/// <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));
Logger.LogInfo($"[Profile] Loaded {_profiles.Count} profiles for quick apply");
}
catch (Exception ex)
{
Logger.LogError($"[Profile] Failed to load profiles: {ex.Message}");
}
}
}

View File

@@ -271,7 +271,7 @@ public:
virtual powertoys_gpo::gpo_rule_configured_t gpo_policy_enabled_configuration() override
{
return powertoys_gpo::gpo_rule_configured_not_configured;
return powertoys_gpo::getConfiguredPowerDisplayEnabledValue();
}
virtual bool get_config(wchar_t* buffer, int* buffer_size) override
@@ -413,8 +413,15 @@ public:
m_enabled = true;
Trace::EnablePowerDisplay(true);
// Launch PowerDisplay.exe with PID only (Awake pattern)
launch_process();
// Launch PowerDisplay.exe if not already running (ColorPicker pattern)
if (!is_process_running())
{
launch_process();
}
else
{
Logger::trace(L"PowerDisplay process already running");
}
}
virtual void disable() override

View File

@@ -148,7 +148,7 @@ namespace Microsoft.PowerToys.Settings.UI.Helpers
case ModuleType.MeasureTool: return GPOWrapper.GetConfiguredScreenRulerEnabledValue();
case ModuleType.ShortcutGuide: return GPOWrapper.GetConfiguredShortcutGuideEnabledValue();
case ModuleType.PowerOCR: return GPOWrapper.GetConfiguredTextExtractorEnabledValue();
case ModuleType.PowerDisplay: return GpoRuleConfigured.Unavailable;
case ModuleType.PowerDisplay: return GPOWrapper.GetConfiguredPowerDisplayEnabledValue();
case ModuleType.ZoomIt: return GPOWrapper.GetConfiguredZoomItEnabledValue();
default: return GpoRuleConfigured.Unavailable;
}

View File

@@ -16,9 +16,14 @@
<controls:SettingsPageControl x:Uid="PowerDisplay" ModuleImageSource="ms-appx:///Assets/Settings/Modules/PowerDisplay.png">
<controls:SettingsPageControl.ModuleContent>
<StackPanel ChildrenTransitions="{StaticResource SettingsCardsAnimations}" Orientation="Vertical">
<tkcontrols:SettingsCard x:Uid="PowerDisplay_Enable_PowerDisplay" HeaderIcon="{ui:BitmapIcon Source=/Assets/Settings/Icons/PowerDisplay.png}">
<ToggleSwitch IsOn="{x:Bind ViewModel.IsEnabled, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
</tkcontrols:SettingsCard>
<controls:GPOInfoControl ShowWarning="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay}">
<tkcontrols:SettingsCard
x:Uid="PowerDisplay_Enable_PowerDisplay"
HeaderIcon="{ui:BitmapIcon Source=/Assets/Settings/Icons/PowerDisplay.png}"
IsEnabled="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}">
<ToggleSwitch IsOn="{x:Bind ViewModel.IsEnabled, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
</tkcontrols:SettingsCard>
</controls:GPOInfoControl>
<controls:SettingsGroup x:Uid="Shortcut" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}">
<tkcontrols:SettingsCard x:Uid="PowerDisplay_ActivationShortcut" HeaderIcon="{ui:FontIcon Glyph=&#xEDA7;}">
@@ -53,34 +58,35 @@
<tkcontrols:SettingsExpander
x:Uid="PowerDisplay_QuickProfiles"
HeaderIcon="{ui:FontIcon Glyph=&#xE8B7;}"
IsExpanded="True"
ItemsSource="{x:Bind ViewModel.Profiles, Mode=OneWay}">
<tkcontrols:SettingsExpander.ItemTemplate>
<DataTemplate x:DataType="pdmodels:PowerDisplayProfile">
<tkcontrols:SettingsCard Header="{x:Bind Name}">
<StackPanel Orientation="Horizontal" Spacing="8">
<Button
x:Uid="PowerDisplay_Profile_ApplyButton"
Click="ProfileButton_Click"
Content="Apply"
Tag="{x:Bind}" />
<Button
x:Uid="PowerDisplay_Profile_MoreButton"
Content="{ui:FontIcon Glyph=&#xE712;,
FontSize=16}"
Style="{StaticResource SubtleButtonStyle}"
Tag="{x:Bind}"
ToolTipService.ToolTip="More settings">
Tag="{x:Bind}">
<Button.Flyout>
<MenuFlyout>
<MenuFlyoutItem
x:Uid="PowerDisplay_Profile_EditMenuItem"
Click="EditProfile_Click"
Icon="{ui:FontIcon Glyph=&#xE70F;}"
Tag="{x:Bind}"
Text="Edit" />
Tag="{x:Bind}" />
<MenuFlyoutSeparator />
<MenuFlyoutItem
x:Uid="PowerDisplay_Profile_DeleteMenuItem"
Click="DeleteProfile_Click"
Icon="{ui:FontIcon Glyph=&#xE74D;}"
Tag="{x:Bind}"
Text="Delete" />
Tag="{x:Bind}" />
</MenuFlyout>
</Button.Flyout>
</Button>
@@ -90,7 +96,7 @@
</tkcontrols:SettingsExpander.ItemTemplate>
<!-- Add profile button -->
<Button Click="AddProfileButton_Click" ToolTipService.ToolTip="Save current settings as new profile">
<Button x:Uid="PowerDisplay_AddProfileButton" Click="AddProfileButton_Click">
<StackPanel Orientation="Horizontal" Spacing="6">
<FontIcon FontSize="14" Glyph="&#xE710;" />
<TextBlock x:Uid="PowerDisplay_AddProfile_Text" />
@@ -126,12 +132,11 @@
<tkcontrols:SettingsExpander.ItemsHeader>
<!-- Capabilities warning -->
<InfoBar
Title="Monitor capabilities unavailable"
x:Uid="PowerDisplay_Monitor_CapabilitiesWarning"
BorderThickness="0"
CornerRadius="0"
IsClosable="False"
IsOpen="True"
Message="This monitor did not report DDC/CI capabilities. Advanced controls may be limited."
Severity="Warning"
Visibility="{x:Bind ShowCapabilitiesWarning, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}" />
</tkcontrols:SettingsExpander.ItemsHeader>
@@ -141,8 +146,8 @@
<StackPanel Spacing="4">
<!-- Simple warning message when supported -->
<TextBlock
x:Uid="PowerDisplay_Monitor_ColorTemperature_Warning"
Foreground="{ThemeResource SystemFillColorCautionBrush}"
Text="Warning: Changing this setting can cause unexpected behavior. Adjust only when its impact is clear."
TextWrapping="Wrap"
Visibility="{x:Bind SupportsColorTemperature, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}" />
<!-- Not supported message -->
@@ -154,19 +159,16 @@
</tkcontrols:SettingsCard.Description>
<ComboBox
x:Name="ColorTemperatureComboBox"
x:Uid="PowerDisplay_Monitor_ColorTemperature_ComboBox"
MinWidth="{StaticResource SettingActionControlMinWidth}"
DisplayMemberPath="DisplayName"
IsEnabled="{Binding SupportsColorTemperature, Mode=OneWay}"
ItemsSource="{Binding ColorPresetsForDisplay, Mode=OneWay}"
PlaceholderText="Not available"
SelectedValue="{Binding ColorTemperatureVcp, Mode=OneWay}"
SelectedValuePath="VcpValue"
SelectionChanged="ColorTemperatureComboBox_SelectionChanged"
Tag="{Binding}">
<ToolTipService.ToolTip>
<TextBlock Text="Changing this setting requires confirmation" />
</ToolTipService.ToolTip>
</ComboBox>
Tag="{Binding}" />
</tkcontrols:SettingsCard>
<tkcontrols:SettingsCard ContentAlignment="Left" IsEnabled="{x:Bind SupportsContrast, Mode=OneWay}">
<CheckBox x:Uid="PowerDisplay_Monitor_EnableContrast" IsChecked="{x:Bind EnableContrast, Mode=TwoWay}" />
@@ -186,16 +188,13 @@
<!-- VCP Capabilities -->
<tkcontrols:SettingsCard
Description="DDC/CI VCP codes and supported values (for debugging purposes)"
Header="VCP capabilities"
x:Uid="PowerDisplay_Monitor_VcpCapabilities"
Visibility="{x:Bind HasCapabilities, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}">
<Button
x:Uid="PowerDisplay_Monitor_VcpDetails_Button"
Content="&#xE946;"
FontFamily="{ThemeResource SymbolThemeFontFamily}"
Style="{StaticResource SubtleButtonStyle}">
<ToolTipService.ToolTip>
<TextBlock Text="View VCP details" />
</ToolTipService.ToolTip>
<Button.Flyout>
<Flyout ShouldConstrainToRootBounds="False">
<Grid Width="420">
@@ -210,21 +209,21 @@
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock
x:Uid="PowerDisplay_Monitor_VcpCodes_Header"
Grid.Column="0"
VerticalAlignment="Center"
FontSize="13"
FontWeight="SemiBold"
Text="Detected VCP Codes" />
FontWeight="SemiBold" />
<Button
x:Uid="PowerDisplay_Monitor_VcpCodes_CopyButton"
Grid.Column="1"
Padding="8,4"
VerticalAlignment="Center"
Click="CopyVcpCodes_Click"
Tag="{x:Bind}"
ToolTipService.ToolTip="Copy all VCP codes to clipboard">
Tag="{x:Bind}">
<StackPanel Orientation="Horizontal" Spacing="4">
<FontIcon FontSize="12" Glyph="&#xE8C8;" />
<TextBlock FontSize="12" Text="Copy" />
<TextBlock x:Uid="PowerDisplay_Monitor_VcpCodes_CopyText" FontSize="12" />
</StackPanel>
</Button>
</Grid>

View File

@@ -90,10 +90,11 @@ namespace Microsoft.PowerToys.Settings.UI.Views
: monitor.ColorTemperatureVcp;
// Show confirmation dialog
var resourceLoader = ResourceLoaderInstance.ResourceLoader;
var dialog = new ContentDialog
{
XamlRoot = this.XamlRoot,
Title = "Confirm Color Temperature Change",
Title = resourceLoader.GetString("PowerDisplay_ColorTemperature_WarningTitle"),
Content = new StackPanel
{
Spacing = 12,
@@ -101,32 +102,32 @@ namespace Microsoft.PowerToys.Settings.UI.Views
{
new TextBlock
{
Text = "⚠️ Warning: This is a potentially dangerous operation!",
Text = resourceLoader.GetString("PowerDisplay_ColorTemperature_WarningHeader"),
FontWeight = Microsoft.UI.Text.FontWeights.Bold,
Foreground = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["SystemFillColorCriticalBrush"],
TextWrapping = TextWrapping.Wrap,
},
new TextBlock
{
Text = "Changing the color temperature setting may cause unpredictable results including:",
Text = resourceLoader.GetString("PowerDisplay_ColorTemperature_WarningDescription"),
TextWrapping = TextWrapping.Wrap,
},
new TextBlock
{
Text = "• Incorrect display colors\n• Display malfunction\n• Settings that cannot be reverted",
Text = resourceLoader.GetString("PowerDisplay_ColorTemperature_WarningList"),
TextWrapping = TextWrapping.Wrap,
Margin = new Thickness(20, 0, 0, 0),
},
new TextBlock
{
Text = "Are you sure you want to proceed with this change?",
Text = resourceLoader.GetString("PowerDisplay_ColorTemperature_WarningConfirm"),
FontWeight = Microsoft.UI.Text.FontWeights.SemiBold,
TextWrapping = TextWrapping.Wrap,
},
},
},
PrimaryButtonText = "Yes, Change Setting",
CloseButtonText = "Cancel",
PrimaryButtonText = resourceLoader.GetString("PowerDisplay_ColorTemperature_PrimaryButton"),
CloseButtonText = resourceLoader.GetString("PowerDisplay_Dialog_Cancel"),
DefaultButton = ContentDialogButton.Close,
};
@@ -207,13 +208,14 @@ namespace Microsoft.PowerToys.Settings.UI.Views
var menuItem = sender as MenuFlyoutItem;
if (menuItem?.Tag is PowerDisplayProfile profile)
{
var resourceLoader = ResourceLoaderInstance.ResourceLoader;
var dialog = new ContentDialog
{
XamlRoot = this.XamlRoot,
Title = "Delete Profile",
Content = $"Are you sure you want to delete '{profile.Name}'?",
PrimaryButtonText = "Delete",
CloseButtonText = "Cancel",
Title = resourceLoader.GetString("PowerDisplay_DeleteProfile_Title"),
Content = string.Format(System.Globalization.CultureInfo.CurrentCulture, resourceLoader.GetString("PowerDisplay_DeleteProfile_Content"), profile.Name),
PrimaryButtonText = resourceLoader.GetString("PowerDisplay_DeleteProfile_PrimaryButton"),
CloseButtonText = resourceLoader.GetString("PowerDisplay_Dialog_Cancel"),
DefaultButton = ContentDialogButton.Close,
};

View File

@@ -46,7 +46,8 @@
<DataTemplate x:DataType="viewmodels:MonitorSelectionItem">
<tkcontrols:SettingsExpander
Margin="0,0,0,8"
Header="{x:Bind Monitor.Name}">
Header="{x:Bind Monitor.Name}"
IsExpanded="True">
<tkcontrols:SettingsExpander.HeaderIcon>
<FontIcon Glyph="{x:Bind Monitor.MonitorIconGlyph}" />
</tkcontrols:SettingsExpander.HeaderIcon>

View File

@@ -5670,4 +5670,102 @@ To record a specific window, enter the hotkey with the Alt key in the opposite m
<data name="ColorTemperatureNotSupportedText.Text" xml:space="preserve">
<value>Color temperature control not supported by this monitor</value>
</data>
<data name="PowerDisplay_ColorTemperature_WarningTitle" xml:space="preserve">
<value>Confirm Color Temperature Change</value>
</data>
<data name="PowerDisplay_ColorTemperature_WarningHeader" xml:space="preserve">
<value>⚠️ Warning: This is a potentially dangerous operation!</value>
</data>
<data name="PowerDisplay_ColorTemperature_WarningDescription" xml:space="preserve">
<value>Changing the color temperature setting may cause unpredictable results including:</value>
</data>
<data name="PowerDisplay_ColorTemperature_WarningList" xml:space="preserve">
<value>• Incorrect display colors
• Display malfunction
• Settings that cannot be reverted</value>
</data>
<data name="PowerDisplay_ColorTemperature_WarningConfirm" xml:space="preserve">
<value>Are you sure you want to proceed with this change?</value>
</data>
<data name="PowerDisplay_ColorTemperature_PrimaryButton" xml:space="preserve">
<value>Yes, Change Setting</value>
</data>
<data name="PowerDisplay_Dialog_Cancel" xml:space="preserve">
<value>Cancel</value>
</data>
<data name="PowerDisplay_DeleteProfile_Title" xml:space="preserve">
<value>Delete Profile</value>
</data>
<data name="PowerDisplay_DeleteProfile_Content" xml:space="preserve">
<value>Are you sure you want to delete '{0}'?</value>
</data>
<data name="PowerDisplay_DeleteProfile_PrimaryButton" xml:space="preserve">
<value>Delete</value>
</data>
<data name="PowerDisplay_Profile_ApplyButton.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
<value>Apply profile</value>
</data>
<data name="PowerDisplay_Profile_MoreButton.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
<value>More profile options</value>
</data>
<data name="PowerDisplay_Profile_EditMenuItem.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
<value>Edit profile</value>
</data>
<data name="PowerDisplay_Profile_DeleteMenuItem.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
<value>Delete profile</value>
</data>
<data name="PowerDisplay_Profile_AddButton.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
<value>Add new profile</value>
</data>
<data name="PowerDisplay_Profile_ApplyButton.Content" xml:space="preserve">
<value>Apply</value>
</data>
<data name="PowerDisplay_Profile_MoreButton.ToolTipService.ToolTip" xml:space="preserve">
<value>More settings</value>
</data>
<data name="PowerDisplay_Profile_EditMenuItem.Text" xml:space="preserve">
<value>Edit</value>
</data>
<data name="PowerDisplay_Profile_DeleteMenuItem.Text" xml:space="preserve">
<value>Delete</value>
</data>
<data name="PowerDisplay_AddProfileButton.ToolTipService.ToolTip" xml:space="preserve">
<value>Save current settings as new profile</value>
</data>
<data name="PowerDisplay_Monitor_CapabilitiesWarning.Title" xml:space="preserve">
<value>Monitor capabilities unavailable</value>
</data>
<data name="PowerDisplay_Monitor_CapabilitiesWarning.Message" xml:space="preserve">
<value>This monitor did not report DDC/CI capabilities. Advanced controls may be limited.</value>
</data>
<data name="PowerDisplay_Monitor_ColorTemperature_Warning.Text" xml:space="preserve">
<value>Warning: Changing this setting can cause unexpected behavior. Adjust only when its impact is clear.</value>
</data>
<data name="PowerDisplay_Monitor_ColorTemperature_ComboBox.PlaceholderText" xml:space="preserve">
<value>Not available</value>
</data>
<data name="PowerDisplay_Monitor_VcpCapabilities.Header" xml:space="preserve">
<value>VCP capabilities</value>
</data>
<data name="PowerDisplay_Monitor_VcpCapabilities.Description" xml:space="preserve">
<value>DDC/CI VCP codes and supported values (for debugging purposes)</value>
</data>
<data name="PowerDisplay_Monitor_VcpDetails_Button.ToolTipService.ToolTip" xml:space="preserve">
<value>View VCP details</value>
</data>
<data name="PowerDisplay_Monitor_VcpDetails_Button.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
<value>View VCP details</value>
</data>
<data name="PowerDisplay_Monitor_VcpCodes_Header.Text" xml:space="preserve">
<value>Detected VCP Codes</value>
</data>
<data name="PowerDisplay_Monitor_VcpCodes_CopyButton.ToolTipService.ToolTip" xml:space="preserve">
<value>Copy all VCP codes to clipboard</value>
</data>
<data name="PowerDisplay_Monitor_VcpCodes_CopyButton.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
<value>Copy VCP codes to clipboard</value>
</data>
<data name="PowerDisplay_Monitor_VcpCodes_CopyText.Text" xml:space="preserve">
<value>Copy</value>
</data>
</root>

View File

@@ -81,9 +81,22 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
});
}
private GpoRuleConfigured _enabledGpoRuleConfiguration;
private bool _enabledStateIsGPOConfigured;
private void InitializeEnabledValue()
{
_isEnabled = GeneralSettingsConfig.Enabled.PowerDisplay;
_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
@@ -91,6 +104,12 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
get => _isEnabled;
set
{
if (_enabledStateIsGPOConfigured)
{
// If it's GPO configured, shouldn't be able to change this state.
return;
}
if (_isEnabled != value)
{
_isEnabled = value;
@@ -103,6 +122,11 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
}
}
public bool IsEnabledGpoConfigured
{
get => _enabledStateIsGPOConfigured;
}
public bool RestoreSettingsOnStartup
{
get => _settings.Properties.RestoreSettingsOnStartup;
@@ -675,6 +699,9 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
// Reload profile list
LoadProfiles();
// Signal PowerDisplay to reload profiles
SignalSettingsUpdated();
Logger.LogInfo($"Profile '{profile.Name}' created successfully");
}
catch (Exception ex)
@@ -708,6 +735,9 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
// Reload profile list
LoadProfiles();
// Signal PowerDisplay to reload profiles
SignalSettingsUpdated();
Logger.LogInfo($"Profile updated to '{newProfile.Name}' successfully");
}
catch (Exception ex)
@@ -737,6 +767,9 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
// Reload profile list
LoadProfiles();
// Signal PowerDisplay to reload profiles
SignalSettingsUpdated();
Logger.LogInfo($"Profile '{profileName}' deleted successfully");
}
catch (Exception ex)