Add display rotation feature with UI controls and settings integration

This commit is contained in:
moooyo
2025-11-28 05:08:55 +08:00
parent 589aaf6f3e
commit bbeea7b2e6
12 changed files with 551 additions and 1 deletions

View File

@@ -325,5 +325,96 @@ namespace PowerDisplay.Common.Drivers
/// The display is rotated 270 degrees (measured clockwise) from its natural orientation.
/// </summary>
public const int Dmdo270 = 3;
// ==================== DEVMODE field flags ====================
/// <summary>
/// DmDisplayOrientation field is valid.
/// </summary>
public const int DmDisplayOrientation = 0x00000080;
/// <summary>
/// DmPelsWidth field is valid.
/// </summary>
public const int DmPelsWidth = 0x00080000;
/// <summary>
/// DmPelsHeight field is valid.
/// </summary>
public const int DmPelsHeight = 0x00100000;
// ==================== ChangeDisplaySettings flags ====================
/// <summary>
/// The settings change is temporary. Not saved to registry.
/// </summary>
public const uint CdsUpdateregistry = 0x00000001;
/// <summary>
/// Test the graphics mode but don't actually set it.
/// </summary>
public const uint CdsTest = 0x00000002;
/// <summary>
/// The mode is fullscreen.
/// </summary>
public const uint CdsFullscreen = 0x00000004;
/// <summary>
/// The settings apply to all users.
/// </summary>
public const uint CdsGlobal = 0x00000008;
/// <summary>
/// Set the primary display.
/// </summary>
public const uint CdsSetPrimary = 0x00000010;
/// <summary>
/// Reset the mode after a dynamic mode change.
/// </summary>
public const uint CdsReset = 0x40000000;
/// <summary>
/// Don't reset the mode.
/// </summary>
public const uint CdsNoreset = 0x10000000;
// ==================== ChangeDisplaySettings result codes ====================
/// <summary>
/// The settings change was successful.
/// </summary>
public const int DispChangeSuccessful = 0;
/// <summary>
/// The computer must be restarted for the graphics mode to work.
/// </summary>
public const int DispChangeRestart = 1;
/// <summary>
/// The display driver failed the specified graphics mode.
/// </summary>
public const int DispChangeFailed = -1;
/// <summary>
/// The graphics mode is not supported.
/// </summary>
public const int DispChangeBadmode = -2;
/// <summary>
/// Unable to write settings to the registry.
/// </summary>
public const int DispChangeNotupdated = -3;
/// <summary>
/// An invalid set of flags was passed in.
/// </summary>
public const int DispChangeBadflags = -4;
/// <summary>
/// An invalid parameter was passed in.
/// </summary>
public const int DispChangeBadparam = -5;
}
}

View File

@@ -63,6 +63,14 @@ namespace PowerDisplay.Common.Drivers
int iModeNum,
DevMode* lpDevMode);
[LibraryImport("user32.dll", EntryPoint = "ChangeDisplaySettingsExW", StringMarshalling = StringMarshalling.Utf16)]
internal static unsafe partial int ChangeDisplaySettingsEx(
[MarshalAs(UnmanagedType.LPWStr)] string? lpszDeviceName,
DevMode* lpDevMode,
IntPtr hwnd,
uint dwflags,
IntPtr lParam);
[LibraryImport("user32.dll")]
internal static partial IntPtr MonitorFromWindow(
IntPtr hwnd,

View File

@@ -0,0 +1,185 @@
// 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 ManagedCommon;
using PowerDisplay.Common.Models;
using static PowerDisplay.Common.Drivers.NativeConstants;
using static PowerDisplay.Common.Drivers.PInvoke;
using DevMode = PowerDisplay.Common.Drivers.DevMode;
namespace PowerDisplay.Common.Services
{
/// <summary>
/// Service for controlling display rotation/orientation.
/// Uses ChangeDisplaySettingsEx API to change display orientation.
/// </summary>
public class DisplayRotationService
{
/// <summary>
/// Set display rotation for a specific monitor.
/// </summary>
/// <param name="monitorNumber">Monitor number (1, 2, 3...)</param>
/// <param name="newOrientation">New orientation: 0=normal, 1=90°, 2=180°, 3=270°</param>
/// <returns>Operation result</returns>
public MonitorOperationResult SetRotation(int monitorNumber, int newOrientation)
{
if (monitorNumber <= 0)
{
return MonitorOperationResult.Failure("Invalid monitor number");
}
if (newOrientation < 0 || newOrientation > 3)
{
return MonitorOperationResult.Failure($"Invalid orientation value: {newOrientation}. Must be 0-3.");
}
// Construct adapter name from monitor number (e.g., 1 -> "\\.\DISPLAY1")
string adapterName = $"\\\\.\\DISPLAY{monitorNumber}";
return SetRotationByAdapterName(adapterName, newOrientation);
}
/// <summary>
/// Set display rotation by adapter name.
/// </summary>
/// <param name="adapterName">Adapter name (e.g., "\\.\DISPLAY1")</param>
/// <param name="newOrientation">New orientation: 0=normal, 1=90°, 2=180°, 3=270°</param>
/// <returns>Operation result</returns>
public unsafe MonitorOperationResult SetRotationByAdapterName(string adapterName, int newOrientation)
{
try
{
Logger.LogInfo($"SetRotation: Setting {adapterName} to orientation {newOrientation}");
// 1. Get current display settings
DevMode devMode = default;
devMode.DmSize = (short)sizeof(DevMode);
if (!EnumDisplaySettings(adapterName, EnumCurrentSettings, &devMode))
{
var error = GetLastError();
Logger.LogError($"SetRotation: EnumDisplaySettings failed for {adapterName}, error: {error}");
return MonitorOperationResult.Failure($"Failed to get current display settings for {adapterName}", (int)error);
}
int currentOrientation = devMode.DmDisplayOrientation;
Logger.LogDebug($"SetRotation: Current orientation={currentOrientation}, target={newOrientation}");
// If already at target orientation, return success
if (currentOrientation == newOrientation)
{
Logger.LogDebug($"SetRotation: Already at target orientation {newOrientation}");
return MonitorOperationResult.Success();
}
// 2. Determine if we need to swap width and height
// When switching between landscape (0°/180°) and portrait (90°/270°), swap dimensions
bool currentIsLandscape = currentOrientation == DmdoDefault || currentOrientation == Dmdo180;
bool newIsLandscape = newOrientation == DmdoDefault || newOrientation == Dmdo180;
if (currentIsLandscape != newIsLandscape)
{
// Swap width and height
int temp = devMode.DmPelsWidth;
devMode.DmPelsWidth = devMode.DmPelsHeight;
devMode.DmPelsHeight = temp;
Logger.LogDebug($"SetRotation: Swapped dimensions to {devMode.DmPelsWidth}x{devMode.DmPelsHeight}");
}
// 3. Set new orientation
devMode.DmDisplayOrientation = newOrientation;
devMode.DmFields = DmDisplayOrientation | DmPelsWidth | DmPelsHeight;
// 4. Test the settings first using CDS_TEST flag
int testResult = ChangeDisplaySettingsEx(adapterName, &devMode, IntPtr.Zero, CdsTest, IntPtr.Zero);
if (testResult != DispChangeSuccessful)
{
string errorMsg = GetChangeDisplaySettingsErrorMessage(testResult);
Logger.LogError($"SetRotation: Test failed for {adapterName}: {errorMsg}");
return MonitorOperationResult.Failure($"Display settings test failed: {errorMsg}", testResult);
}
Logger.LogDebug($"SetRotation: Test passed, applying settings...");
// 5. Apply the settings (without CDS_UPDATEREGISTRY to make it temporary)
int result = ChangeDisplaySettingsEx(adapterName, &devMode, IntPtr.Zero, 0, IntPtr.Zero);
if (result != DispChangeSuccessful)
{
string errorMsg = GetChangeDisplaySettingsErrorMessage(result);
Logger.LogError($"SetRotation: Apply failed for {adapterName}: {errorMsg}");
return MonitorOperationResult.Failure($"Failed to apply display settings: {errorMsg}", result);
}
Logger.LogInfo($"SetRotation: Successfully set {adapterName} to orientation {newOrientation}");
return MonitorOperationResult.Success();
}
catch (Exception ex)
{
Logger.LogError($"SetRotation: Exception for {adapterName}: {ex.Message}");
return MonitorOperationResult.Failure($"Exception while setting rotation: {ex.Message}");
}
}
/// <summary>
/// Get current rotation for a specific monitor.
/// </summary>
/// <param name="monitorNumber">Monitor number (1, 2, 3...)</param>
/// <returns>Current orientation (0-3), or -1 if failed</returns>
public int GetCurrentRotation(int monitorNumber)
{
if (monitorNumber <= 0)
{
return -1;
}
string adapterName = $"\\\\.\\DISPLAY{monitorNumber}";
return GetCurrentRotationByAdapterName(adapterName);
}
/// <summary>
/// Get current rotation by adapter name.
/// </summary>
/// <param name="adapterName">Adapter name (e.g., "\\.\DISPLAY1")</param>
/// <returns>Current orientation (0-3), or -1 if failed</returns>
public unsafe int GetCurrentRotationByAdapterName(string adapterName)
{
try
{
DevMode devMode = default;
devMode.DmSize = (short)sizeof(DevMode);
if (EnumDisplaySettings(adapterName, EnumCurrentSettings, &devMode))
{
return devMode.DmDisplayOrientation;
}
}
catch (Exception ex)
{
Logger.LogError($"GetCurrentRotation: Exception for {adapterName}: {ex.Message}");
}
return -1;
}
/// <summary>
/// Get human-readable error message for ChangeDisplaySettings result code.
/// </summary>
private static string GetChangeDisplaySettingsErrorMessage(int resultCode)
{
return resultCode switch
{
DispChangeSuccessful => "Success",
DispChangeRestart => "Computer must be restarted",
DispChangeFailed => "Display driver failed the specified graphics mode",
DispChangeBadmode => "Graphics mode is not supported",
DispChangeNotupdated => "Unable to write settings to registry",
DispChangeBadflags => "Invalid flags",
DispChangeBadparam => "Invalid parameter",
_ => $"Unknown error code: {resultCode}",
};
}
}
}

View File

@@ -13,6 +13,7 @@ using PowerDisplay.Common.Drivers.DDC;
using PowerDisplay.Common.Drivers.WMI;
using PowerDisplay.Common.Interfaces;
using PowerDisplay.Common.Models;
using PowerDisplay.Common.Services;
using PowerDisplay.Common.Utils;
using PowerDisplay.Core.Interfaces;
using Monitor = PowerDisplay.Common.Models.Monitor;
@@ -29,6 +30,7 @@ namespace PowerDisplay.Core
private readonly Dictionary<string, Monitor> _monitorLookup = new();
private readonly List<IMonitorController> _controllers = new();
private readonly SemaphoreSlim _discoveryLock = new(1, 1);
private readonly DisplayRotationService _rotationService = new();
private bool _disposed;
public IReadOnlyList<Monitor> Monitors => _monitors.AsReadOnly();
@@ -486,6 +488,46 @@ namespace PowerDisplay.Core
(mon, val) => mon.CurrentInputSource = val,
cancellationToken);
/// <summary>
/// Set rotation/orientation for a monitor.
/// Uses Windows ChangeDisplaySettingsEx API (not DDC/CI).
/// </summary>
/// <param name="monitorId">Monitor ID</param>
/// <param name="orientation">Orientation: 0=normal, 1=90°, 2=180°, 3=270°</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Operation result</returns>
public Task<MonitorOperationResult> SetRotationAsync(string monitorId, int orientation, CancellationToken cancellationToken = default)
{
var monitor = GetMonitor(monitorId);
if (monitor == null)
{
Logger.LogError($"[MonitorManager] SetRotation: Monitor not found: {monitorId}");
return Task.FromResult(MonitorOperationResult.Failure("Monitor not found"));
}
if (monitor.MonitorNumber <= 0)
{
Logger.LogError($"[MonitorManager] SetRotation: Invalid monitor number for {monitorId}");
return Task.FromResult(MonitorOperationResult.Failure("Invalid monitor number"));
}
// Rotation uses Windows display settings API, not DDC/CI controller
var result = _rotationService.SetRotation(monitor.MonitorNumber, orientation);
if (result.IsSuccess)
{
monitor.Orientation = orientation;
monitor.LastUpdate = DateTime.Now;
Logger.LogInfo($"[MonitorManager] SetRotation: Successfully set {monitorId} to orientation {orientation}");
}
else
{
Logger.LogError($"[MonitorManager] SetRotation: Failed for {monitorId}: {result.ErrorMessage}");
}
return Task.FromResult(result);
}
/// <summary>
/// Initialize input source for a monitor (async operation)
/// </summary>

View File

@@ -264,6 +264,72 @@
ValueChanged="Slider_ValueChanged"
Value="{x:Bind Volume, Mode=OneWay}" />
</Grid>
<!-- Rotation Controls -->
<Grid
Margin="0,8,0,0"
HorizontalAlignment="Stretch"
Visibility="{x:Bind ConvertBoolToVisibility(ShowRotation), Mode=OneWay}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="16" />
<ColumnDefinition Width="16" />
<!-- Spacing -->
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<FontIcon
x:Uid="RotationTooltip"
VerticalAlignment="Center"
AutomationProperties.AccessibilityView="Raw"
FontSize="16"
Glyph="&#xE7AD;" />
<StackPanel
Grid.Column="2"
Orientation="Horizontal"
Spacing="4">
<!-- Normal (0°) -->
<ToggleButton
x:Uid="RotateNormalTooltip"
Click="RotationButton_Click"
DataContext="{x:Bind}"
IsChecked="{x:Bind IsRotation0, Mode=OneWay}"
IsEnabled="{x:Bind IsInteractionEnabled, Mode=OneWay}"
Tag="0">
<FontIcon FontSize="14" Glyph="&#xE74A;" />
</ToggleButton>
<!-- Left (270°) -->
<ToggleButton
x:Uid="RotateLeftTooltip"
Click="RotationButton_Click"
DataContext="{x:Bind}"
IsChecked="{x:Bind IsRotation3, Mode=OneWay}"
IsEnabled="{x:Bind IsInteractionEnabled, Mode=OneWay}"
Tag="3">
<FontIcon FontSize="14" Glyph="&#xE76B;" />
</ToggleButton>
<!-- Right (90°) -->
<ToggleButton
x:Uid="RotateRightTooltip"
Click="RotationButton_Click"
DataContext="{x:Bind}"
IsChecked="{x:Bind IsRotation1, Mode=OneWay}"
IsEnabled="{x:Bind IsInteractionEnabled, Mode=OneWay}"
Tag="1">
<FontIcon FontSize="14" Glyph="&#xE76C;" />
</ToggleButton>
<!-- Inverted (180°) -->
<ToggleButton
x:Uid="RotateInvertedTooltip"
Click="RotationButton_Click"
DataContext="{x:Bind}"
IsChecked="{x:Bind IsRotation2, Mode=OneWay}"
IsEnabled="{x:Bind IsInteractionEnabled, Mode=OneWay}"
Tag="2">
<FontIcon FontSize="14" Glyph="&#xE74B;" />
</ToggleButton>
</StackPanel>
</Grid>
</StackPanel>
</StackPanel>
</DataTemplate>

View File

@@ -768,6 +768,43 @@ namespace PowerDisplay
}
}
/// <summary>
/// Rotation button click handler - changes monitor orientation
/// </summary>
private async void RotationButton_Click(object sender, RoutedEventArgs e)
{
if (sender is not Microsoft.UI.Xaml.Controls.Primitives.ToggleButton toggleButton)
{
return;
}
// Get the orientation from the Tag
if (toggleButton.Tag is not string tagStr || !int.TryParse(tagStr, out int orientation))
{
Logger.LogWarning("[UI] RotationButton_Click: Invalid Tag");
return;
}
var monitorVm = toggleButton.DataContext as MonitorViewModel;
if (monitorVm == null)
{
Logger.LogWarning("[UI] RotationButton_Click: Could not find MonitorViewModel");
return;
}
// If clicking the current orientation, restore the checked state and do nothing
if (monitorVm.CurrentRotation == orientation)
{
toggleButton.IsChecked = true;
return;
}
Logger.LogInfo($"[UI] RotationButton_Click: Setting rotation for {monitorVm.Name} to {orientation}");
// Set the rotation
await monitorVm.SetRotationAsync(orientation);
}
public void Dispose()
{
_viewModel?.Dispose();

View File

@@ -44,6 +44,21 @@
</data>
<data name="VolumeTooltip.ToolTipService.ToolTip" xml:space="preserve">
<value>Volume</value>
</data>
<data name="RotationTooltip.ToolTipService.ToolTip" xml:space="preserve">
<value>Rotation</value>
</data>
<data name="RotateNormalTooltip.ToolTipService.ToolTip" xml:space="preserve">
<value>Normal (0°)</value>
</data>
<data name="RotateLeftTooltip.ToolTipService.ToolTip" xml:space="preserve">
<value>Rotate left (270°)</value>
</data>
<data name="RotateRightTooltip.ToolTipService.ToolTip" xml:space="preserve">
<value>Rotate right (90°)</value>
</data>
<data name="RotateInvertedTooltip.ToolTipService.ToolTip" xml:space="preserve">
<value>Inverted (180°)</value>
</data>
<data name="VolumeAutomation.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
<value>Volume</value>

View File

@@ -452,11 +452,12 @@ public partial class MainViewModel
if (monitorSettings != null)
{
Logger.LogInfo($"[Startup] Applying feature visibility for Hardware ID '{monitorVm.HardwareId}': Contrast={monitorSettings.EnableContrast}, Volume={monitorSettings.EnableVolume}, InputSource={monitorSettings.EnableInputSource}");
Logger.LogInfo($"[Startup] Applying feature visibility for Hardware ID '{monitorVm.HardwareId}': Contrast={monitorSettings.EnableContrast}, Volume={monitorSettings.EnableVolume}, InputSource={monitorSettings.EnableInputSource}, Rotation={monitorSettings.EnableRotation}");
monitorVm.ShowContrast = monitorSettings.EnableContrast;
monitorVm.ShowVolume = monitorSettings.EnableVolume;
monitorVm.ShowInputSource = monitorSettings.EnableInputSource;
monitorVm.ShowRotation = monitorSettings.EnableRotation;
}
else
{
@@ -608,6 +609,7 @@ public partial class MainViewModel
monitorInfo.EnableContrast = existingMonitor.EnableContrast;
monitorInfo.EnableVolume = existingMonitor.EnableVolume;
monitorInfo.EnableInputSource = existingMonitor.EnableInputSource;
monitorInfo.EnableRotation = existingMonitor.EnableRotation;
}
}

View File

@@ -44,6 +44,7 @@ public partial class MonitorViewModel : INotifyPropertyChanged, IDisposable
private bool _showContrast;
private bool _showVolume;
private bool _showInputSource;
private bool _showRotation;
/// <summary>
/// Updates a property value directly without triggering hardware updates.
@@ -356,6 +357,87 @@ public partial class MonitorViewModel : INotifyPropertyChanged, IDisposable
}
}
/// <summary>
/// Gets or sets whether to show rotation controls (controlled by Settings UI, default false)
/// </summary>
public bool ShowRotation
{
get => _showRotation;
set
{
if (_showRotation != value)
{
_showRotation = value;
OnPropertyChanged();
}
}
}
/// <summary>
/// Gets the current rotation/orientation of the monitor (0=normal, 1=90°, 2=180°, 3=270°)
/// </summary>
public int CurrentRotation => _monitor.Orientation;
/// <summary>
/// Gets whether the current rotation is 0° (normal/default)
/// </summary>
public bool IsRotation0 => CurrentRotation == 0;
/// <summary>
/// Gets whether the current rotation is 90° (rotated right)
/// </summary>
public bool IsRotation1 => CurrentRotation == 1;
/// <summary>
/// Gets whether the current rotation is 180° (inverted)
/// </summary>
public bool IsRotation2 => CurrentRotation == 2;
/// <summary>
/// Gets whether the current rotation is 270° (rotated left)
/// </summary>
public bool IsRotation3 => CurrentRotation == 3;
/// <summary>
/// Set rotation/orientation for this monitor
/// </summary>
/// <param name="orientation">Orientation: 0=normal, 1=90°, 2=180°, 3=270°</param>
public async Task SetRotationAsync(int orientation)
{
// If already at this orientation, do nothing
if (CurrentRotation == orientation)
{
return;
}
try
{
Logger.LogInfo($"[{HardwareId}] Setting rotation to {orientation}");
var result = await _monitorManager.SetRotationAsync(Id, orientation);
if (result.IsSuccess)
{
// Notify all rotation-related properties changed
OnPropertyChanged(nameof(CurrentRotation));
OnPropertyChanged(nameof(IsRotation0));
OnPropertyChanged(nameof(IsRotation1));
OnPropertyChanged(nameof(IsRotation2));
OnPropertyChanged(nameof(IsRotation3));
Logger.LogInfo($"[{HardwareId}] Rotation set successfully to {orientation}");
}
else
{
Logger.LogWarning($"[{HardwareId}] Failed to set rotation: {result.ErrorMessage}");
}
}
catch (Exception ex)
{
Logger.LogError($"[{HardwareId}] Exception setting rotation: {ex.Message}");
}
}
public int Brightness
{
get => _brightness;

View File

@@ -29,6 +29,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library
private bool _enableContrast;
private bool _enableVolume;
private bool _enableInputSource;
private bool _enableRotation;
private string _capabilitiesRaw = string.Empty;
private List<string> _vcpCodes = new List<string>();
private List<VcpCodeDisplayInfo> _vcpCodesFormatted = new List<VcpCodeDisplayInfo>();
@@ -414,6 +415,20 @@ namespace Microsoft.PowerToys.Settings.UI.Library
}
}
[JsonPropertyName("enableRotation")]
public bool EnableRotation
{
get => _enableRotation;
set
{
if (_enableRotation != value)
{
_enableRotation = value;
OnPropertyChanged();
}
}
}
[JsonPropertyName("capabilitiesRaw")]
public string CapabilitiesRaw
{
@@ -792,6 +807,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library
EnableContrast = other.EnableContrast;
EnableVolume = other.EnableVolume;
EnableInputSource = other.EnableInputSource;
EnableRotation = other.EnableRotation;
CapabilitiesRaw = other.CapabilitiesRaw;
VcpCodes = other.VcpCodes;
VcpCodesFormatted = other.VcpCodesFormatted;

View File

@@ -177,6 +177,9 @@
<tkcontrols:SettingsCard ContentAlignment="Left" IsEnabled="{x:Bind SupportsInputSource, Mode=OneWay}">
<CheckBox x:Uid="PowerDisplay_Monitor_EnableInputSource" IsChecked="{x:Bind EnableInputSource, Mode=TwoWay}" />
</tkcontrols:SettingsCard>
<tkcontrols:SettingsCard ContentAlignment="Left">
<CheckBox x:Uid="PowerDisplay_Monitor_EnableRotation" IsChecked="{x:Bind EnableRotation, Mode=TwoWay}" />
</tkcontrols:SettingsCard>
<tkcontrols:SettingsCard ContentAlignment="Left">
<CheckBox x:Uid="PowerDisplay_Monitor_HideMonitor" IsChecked="{x:Bind IsHidden, Mode=TwoWay}" />
</tkcontrols:SettingsCard>

View File

@@ -5661,6 +5661,9 @@ To record a specific window, enter the hotkey with the Alt key in the opposite m
<data name="PowerDisplay_Monitor_EnableInputSource.Content" xml:space="preserve">
<value>Show input source control</value>
</data>
<data name="PowerDisplay_Monitor_EnableRotation.Content" xml:space="preserve">
<value>Show rotation control</value>
</data>
<data name="PowerDisplay_Monitor_HideMonitor.Content" xml:space="preserve">
<value>Hide monitor</value>
</data>