diff --git a/.github/actions/spell-check/expect.txt b/.github/actions/spell-check/expect.txt index 7c1b9f65dd..a43e81d077 100644 --- a/.github/actions/spell-check/expect.txt +++ b/.github/actions/spell-check/expect.txt @@ -11,6 +11,7 @@ ACCESSDENIED ACCESSTOKEN acfs ACIE +ACR AClient AColumn acrt @@ -58,7 +59,6 @@ AOC aocfnapldcnfbofgmbbllojgocaelgdd AOklab aot -APARTMENTTHREADED APeriod apicontract apidl @@ -96,6 +96,7 @@ asf Ashcraft AShortcut ASingle +ASUS ASSOCCHANGED ASSOCF ASSOCSTR @@ -105,6 +106,7 @@ atl ATRIOX aumid authenticode +AUO AUTOBUDDY AUTOCHECKBOX AUTOHIDE @@ -122,6 +124,10 @@ azureaiinference azureinference azureopenai backticks +Backlight +Badflags +Badmode +Badparam bbwe BCIE bck @@ -195,6 +201,7 @@ Carlseibert CAtl caub CBN +Cds cch CCHDEVICENAME CCHFORMNAME @@ -214,6 +221,7 @@ checkmarks CHILDACTIVATE CHILDWINDOW CHOOSEFONT +Chunghwa CIBUILD cidl CIELCh @@ -228,7 +236,7 @@ claude CLEARTYPE clickable clickonce -CLIENTEDGE +clientedge clientid clientside CLIPBOARDUPDATE @@ -240,6 +248,7 @@ CLSCTX clsids Clusion cmder +CMN CMDNOTFOUNDMODULEINTERFACE cmdpal CMIC @@ -294,6 +303,7 @@ Corpor cotaskmem COULDNOT countof +Cowait covrun cpcontrols cph @@ -312,12 +322,14 @@ CRECT CRH critsec cropandlock +crt CROPTOSQUARE Crossdevice csdevkit CSearch CSettings cso +CSOT CSRW CStyle cswin @@ -360,11 +372,14 @@ DBPROPIDSET DBPROPSET DBT DCBA +DCapabilities DCOM DComposition DCR +ddc DDEIf Deact +debouncer debugbreak decryptor Dedup @@ -382,6 +397,7 @@ DEFAULTTOPRIMARY DEFERERASE DEFPUSHBUTTON deinitialization +DELA DELETEDKEYIMAGE DELETESCANS DEMOTYPE @@ -416,18 +432,20 @@ DISABLEASACTIONKEY DISABLENOSCROLL diskmgmt DISPLAYCHANGE -DISPLAYCONFIG +displayconfig DISPLAYFLAGS DISPLAYFREQUENCY displayname DISPLAYORIENTATION +diu divyan Dlg DLGFRAME -DLGMODALFRAME +dlgmodalframe dlib dllhost dllmain +Dmdo DNLEN DONOTROUND DONTVALIDATEPATH @@ -437,6 +455,7 @@ downsampling downscale DPICHANGED DPIs +DPMS DPSAPI DQTAT DQTYPE @@ -474,15 +493,19 @@ DWMWINDOWMAXIMIZEDCHANGE DWORDLONG dworigin dwrite +Dxva dxgi eab +EAccess easeofaccess ecount -Edid +edid EDITKEYBOARD EDITSHORTCUTS EDITTEXT EFile +EInvalid +eep eku emojis ENABLEDELAYEDEXPANSION @@ -492,14 +515,15 @@ ENABLETEMPLATE encodedlaunch encryptor ENDSESSION +ENot ENSUREVISIBLE ENTERSIZEMOVE ENTRYW ENU environmentvariables -EOAC EPO epu +EProvider ERASEBKGND EREOF EResize @@ -553,6 +577,7 @@ fdx FErase fesf FFFF +FFh Figma FILEEXPLORER fileexploreraddons @@ -595,6 +620,7 @@ formatetc FORPARSING foundrylocal FRAMECHANGED +Framechanged FRestore frm FROMTOUCH @@ -656,6 +682,8 @@ gwl GWLP GWLSTYLE hangeul +Hann +Hantai Hanzi Hardlines hardlinks @@ -714,6 +742,7 @@ HKPD HKU HMD hmenu +HMON hmodule hmonitor homies @@ -731,6 +760,7 @@ hotkeys hotlight hotspot HPAINTBUFFER +HPhysical HRAWINPUT hredraw hres @@ -741,6 +771,7 @@ hsb HSCROLL hsi HSpeed +HSync HTCLIENT hthumbnail HTOUCHINPUT @@ -750,6 +781,7 @@ HVal HValue Hvci hwb +HWP HWHEEL HWINEVENTHOOK hwnd @@ -807,6 +839,7 @@ INITTOLOGFONTSTRUCT INLINEPREFIX inlines Inno +Innolux INPC inproc INPUTHARDWARE @@ -848,6 +881,7 @@ istep ith ITHUMBNAIL IUI +IVO IUWP IWIC jeli @@ -861,6 +895,7 @@ jpnime Jsons jsonval jxr +Kantai keybd KEYBDDATA KEYBDINPUT @@ -882,6 +917,7 @@ KILLFOCUS killrunner kmph kvp +KVM Kybd LARGEICON lastcodeanalysissucceeded @@ -903,6 +939,8 @@ LEFTTEXT LError LEVELID LExit +Lenovo +LGD LFU lhwnd LIBFUZZER @@ -1008,6 +1046,7 @@ MAPTOSAMESHORTCUT MAPVK MARKDOWNPREVIEWHANDLERCPP MAXIMIZEBOX +Maximizebox MAXSHORTCUTSIZE maxversiontested mber @@ -1020,6 +1059,7 @@ MDL mdtext mdtxt mdwn +mccs meme memicmp MENUITEMINFO @@ -1029,9 +1069,7 @@ MERGEPAINT Metacharacter metadatamatters Metadatas -Metacharacter metafile -Metacharacter mfc Mgmt Microwaved @@ -1043,6 +1081,7 @@ mikeclayton mindaro Minimizable MINIMIZEBOX +Minimizebox MINIMIZEEND MINIMIZESTART MINMAXINFO @@ -1079,6 +1118,7 @@ MOVESIZEEND MOVESIZESTART MRM Mrt +mrt mru MSAL msc @@ -1104,6 +1144,7 @@ Mso msrc msstore mstsc +mswhql msvcp MT MTND @@ -1121,6 +1162,7 @@ MYICON myorg myrepo NAMECHANGE +Nanjing namespaceanddescendants nao NCACTIVATE @@ -1189,6 +1231,7 @@ NOMCX NOMINMAX NOMIRRORBITMAP NOMOVE +Nomove NONANTIALIASED nonclient NONCLIENTMETRICSW @@ -1210,6 +1253,7 @@ NORMALUSER NOSEARCH NOSENDCHANGING NOSIZE +Nosize NOTHOUSANDS NOTICKS NOTIFICATIONSDLL @@ -1217,9 +1261,11 @@ NOTIFYICONDATA NOTIFYICONDATAW NOTIMPL NOTOPMOST +Notopmost NOTRACK NOTSRCCOPY NOTSRCERASE +Notupdated notwindows NOTXORPEN nowarn @@ -1263,6 +1309,7 @@ opensource openurl openxmlformats OPTIMIZEFORINVOKE +Optronics ORPHANEDDIALOGTITLE ORSCANS oss @@ -1298,6 +1345,7 @@ PATINVERT PATPAINT pbc pbi +PBP PBlob pbrush pcb @@ -1312,6 +1360,7 @@ PDBs PDEVMODE pdisp PDLL +pdmodels pdo pdto pdtobj @@ -1334,6 +1383,7 @@ pguid phbm phbmp phicon +PHL Photoshop phwnd pici @@ -1366,6 +1416,8 @@ Popups POPUPWINDOW POSITIONITEM POWERBROADCAST +powerdisplay +POWERDISPLAYMODULEINTERFACE POWERRENAMECONTEXTMENU powerrenameinput POWERRENAMETEST @@ -1420,6 +1472,7 @@ projectname PROPERTYKEY Propset PROPVARIANT +prot PRTL prvpane psapi @@ -1447,12 +1500,16 @@ PTOKEN PToy ptstr pui +pvct PWAs pwcs PWSTR pwsz pwtd +Qdc QDC +qdc +QDS qit QITAB QITABENT @@ -1740,6 +1797,7 @@ STARTUPINFOW startupscreen STATFLAG STATICEDGE +Staticedge staticmethod STATSTG stdafx @@ -1776,6 +1834,7 @@ subkeys sublang SUBMODULEUPDATE subresource +swp Superbar sut svchost @@ -1784,7 +1843,8 @@ SVGIO svgz SVSI SWFO -swp +SWP +Swp SWPNOSIZE SWPNOZORDER SWRESTORE @@ -1844,7 +1904,9 @@ THEMECHANGED themeresources THH THICKFRAME +Thickframe THISCOMPONENT +Tianma throughs TILEDWINDOW TILLSON @@ -1925,13 +1987,13 @@ UNLEN UNORM unremapped Unsubscribes +unsubscribes unvirtualized unwide unzoom UOffset UOI UPDATENOW -UPDATEREGISTRY updown UPGRADINGPRODUCTCODE upscaling @@ -1958,6 +2020,8 @@ vcamp vcenter vcgtq VCINSTALLDIR +vcp +vcpname Vcpkg VCRT vcruntime @@ -1970,6 +2034,8 @@ VERIFYCONTEXT VERSIONINFO VERTRES VERTSIZE +VESA +vesa VFT vget vgetq @@ -2001,6 +2067,7 @@ VSM vso vsonline VSpeed +VSync vstemplate vstest VSTHRD @@ -2042,7 +2109,7 @@ winapi winappsdk windir WINDOWCREATED -WINDOWEDGE +windowedge WINDOWINFO WINDOWNAME WINDOWPLACEMENT @@ -2066,12 +2133,12 @@ WINL winlogon winmd winml -WINNT winres winrt winsdk winsta WINTHRESHOLD +WINNT WINVER winxamlmanager withinrafael @@ -2083,6 +2150,7 @@ WKSG Wlkr wmain Wman +wmi WMI WMICIM wmimgmt @@ -2095,6 +2163,7 @@ WNDCLASSEX WNDCLASSEXW WNDCLASSW WNDPROC +Wndproc wnode wom WORKSPACESEDITOR diff --git a/.github/actions/spell-check/patterns.txt b/.github/actions/spell-check/patterns.txt index 024cef81a1..34b2ad9fe9 100644 --- a/.github/actions/spell-check/patterns.txt +++ b/.github/actions/spell-check/patterns.txt @@ -274,5 +274,18 @@ St&yle # 0x6f677548 is user name but user folder causes a flag \bx6f677548\b +# Windows API constants and hardware interface terms +\bCOINIT[_A-Z]*\b +\bEOAC[_A-Z]*\b +\b(?:RPC_C_AUTHN_)?WINNT\b +\bUPDATEREGISTRY\b +\b(?:CDS_)?UPDATEREGISTRY\b + +# Display interface terms (HDMI, DVI, DisplayPort) +\b(?:HDMI|DVI|DisplayPort)(?:-\d+)?\b + +# 2D Region struct names +\bDisplayConfig2?D?Region\b + # Microsoft Store URLs and product IDs ms-windows-store://\S+ diff --git a/.pipelines/ESRPSigning_core.json b/.pipelines/ESRPSigning_core.json index c1cd63aef0..6c51889d77 100644 --- a/.pipelines/ESRPSigning_core.json +++ b/.pipelines/ESRPSigning_core.json @@ -210,6 +210,11 @@ "PowerToys.PowerAccentModuleInterface.dll", "PowerToys.PowerAccentKeyboardService.dll", + "PowerToys.PowerDisplayModuleInterface.dll", + "WinUI3Apps\\PowerToys.PowerDisplay.dll", + "WinUI3Apps\\PowerToys.PowerDisplay.exe", + "PowerDisplay.Lib.dll", + "WinUI3Apps\\PowerToys.PowerRenameExt.dll", "WinUI3Apps\\PowerToys.PowerRename.exe", "WinUI3Apps\\PowerToys.PowerRenameContextMenu.dll", @@ -378,6 +383,8 @@ "UnitsNet.dll", "UtfUnknown.dll", "Wpf.Ui.dll", + "WmiLight.dll", + "WmiLight.Native.dll", "Shmuelie.WinRTServer.dll", "ToolGood.Words.Pinyin.dll" ], diff --git a/Directory.Packages.props b/Directory.Packages.props index 6b75c4159a..750a57fdfc 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -93,6 +93,7 @@ + @@ -104,6 +105,7 @@ + @@ -133,6 +135,7 @@ + diff --git a/NOTICE.md b/NOTICE.md index 4273edbb18..e1a32d6f76 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -10,6 +10,7 @@ This software incorporates material from third parties. - Installer/Runner - Measure tool - Peek +- PowerDisplay - Registry Preview ## Utility: Color Picker @@ -1519,6 +1520,35 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` +## Utility: PowerDisplay + +### Twinkle Tray + +PowerDisplay's DDC/CI implementation references techniques from Twinkle Tray. + +**Source**: https://github.com/xanderfrangos/twinkle-tray + +MIT License + +Copyright © 2020 Xander Frangos + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. ## NuGet Packages used by PowerToys @@ -1557,6 +1587,7 @@ SOFTWARE. - NLog.Extensions.Logging - NLog.Schema - OpenAI +- Polly.Core - ReverseMarkdown - ScipBe.Common.Office.OneNote - SharpCompress @@ -1569,5 +1600,6 @@ SOFTWARE. - UnitsNet - UTF.Unknown - WinUIEx +- WmiLight - WPF-UI - WyHash \ No newline at end of file diff --git a/PowerToys.slnx b/PowerToys.slnx index 1dc26be394..e94d8a079d 100644 --- a/PowerToys.slnx +++ b/PowerToys.slnx @@ -684,6 +684,23 @@ + + + + + + + + + + + + + + + + + diff --git a/doc/devdocs/modules/powerdisplay/design.md b/doc/devdocs/modules/powerdisplay/design.md new file mode 100644 index 0000000000..ae2eb26479 --- /dev/null +++ b/doc/devdocs/modules/powerdisplay/design.md @@ -0,0 +1,1616 @@ +# PowerDisplay Module Design Document + +## Table of Contents + +1. [Background](#background) +2. [Problem Statement](#problem-statement) +3. [Goals](#goals) +4. [Technical Terminology](#technical-terminology) + - [DDC/CI (Display Data Channel Command Interface)](#ddcci-display-data-channel-command-interface) + - [WMI (Windows Management Instrumentation)](#wmi-windows-management-instrumentation) +5. [Architecture Overview](#architecture-overview) + - [High-Level Component Architecture](#high-level-component-architecture) + - [Project Structure](#project-structure) +6. [Component Design](#component-design) + - [PowerDisplay Module Internal Structure](#powerdisplay-module-internal-structure) + - [DisplayChangeWatcher - Monitor Hot-Plug Detection](#displaychangewatcher---monitor-hot-plug-detection) + - [DDC/CI and WMI Interaction Architecture](#ddcci-and-wmi-interaction-architecture) + - [IMonitorController Interface Methods](#imonitorcontroller-interface-methods) + - [Why WmiLight Instead of System.Management](#why-wmilight-instead-of-systemmanagement) + - [Why We Need an MCCS Capabilities String Parser](#why-we-need-an-mccs-capabilities-string-parser) + - [Monitor Identification: Handles, IDs, and Names](#monitor-identification-handles-ids-and-names) + - [Settings UI and PowerDisplay Interaction Architecture](#settings-ui-and-powerdisplay-interaction-architecture) + - [Windows Events for IPC](#windows-events-for-ipc) + - [LightSwitch Profile Integration Architecture](#lightswitch-profile-integration-architecture) + - [LightSwitch Settings JSON Structure](#lightswitch-settings-json-structure) +7. [Data Flow and Communication](#data-flow-and-communication) + - [Monitor Discovery Flow](#monitor-discovery-flow) +8. [Sequence Diagrams](#sequence-diagrams) + - [Sequence: Modifying Color Temperature in Flyout UI](#sequence-modifying-color-temperature-in-flyout-ui) + - [Sequence: Creating and Saving a Profile](#sequence-creating-and-saving-a-profile) + - [Sequence: Applying Profile via LightSwitch Theme Change](#sequence-applying-profile-via-lightswitch-theme-change) + - [Sequence: UI Slider Adjustment (Brightness)](#sequence-ui-slider-adjustment-brightness) + - [Sequence: Module Enable/Disable Lifecycle](#sequence-module-enabledisable-lifecycle) +9. [Future Considerations](#future-considerations) + - [Already Implemented](#already-implemented) + - [Potential Future Enhancements](#potential-future-enhancements) +10. [References](#references) + +--- + +## Background + +PowerDisplay is a PowerToys module designed to provide unified control over display +settings across multiple monitors. Users often work with multiple displays (external monitors or laptop screens) and need a +convenient way to adjust display parameters such as brightness, contrast, color +temperature, volume, and input source without navigating through individual monitor +OSD menus. + +The module leverages two primary technologies for monitor control: + +1. **DDC/CI (Display Data Channel Command Interface)** - For external monitors +2. **WMI (Windows Management Instrumentation)** - For internal(laptop) displays + +--- + +## Problem Statement + +Users with multiple monitors face several challenges: + +1. **Fragmented Control**: Each monitor requires separate OSD navigation +2. **Inconsistent Brightness**: Difficult to maintain uniform brightness across displays +3. **No Profile Support**: Cannot quickly switch display configurations for different + scenarios (gaming, productivity, movie watching) +4. **Theme Integration Gap**: No automatic display adjustment when switching between + light and dark themes + +--- + +## Goals + +- Provide unified control for brightness, contrast, volume, color temperature, and + input source across all connected monitors +- Support both DDC/CI (external monitors) and WMI (laptop displays) +- Support user-defined profiles for quick configuration switching +- Integrate with LightSwitch module for automatic profile application on theme changes +- Support global hotkey activation + +--- + +## Technical Terminology + +### DDC/CI (Display Data Channel Command Interface) + +**DDC/CI** is a VESA standard (defined in the DDC specification) that allows +bidirectional communication between a computer and a display over the I2C bus +embedded in display cables. + +Most external monitors support DDC/CI, allowing applications to read and modify settings +like brightness and contrast programmatically. But unfortunately, some manufacturers have poor implementations of their product's driver. They may not support DDC/CI or report itself supports DDC/CI (through capabilities string) when it does not. Even if a monitor supports DDC/CI, they may only support a limited subset of VCP codes, or have buggy implementations. + +And sometimes, users may connect monitor through a KVM switch or docking station that does not pass through DDC/CI commands correctly, and their docking may report it supports (hard code a capabilities string) but in reality, it does not. And will do thing when we try to send DDC/CI commands. + +PowerDisplay relies on the monitor-reported capabilities string to determine supported features. But if your monitor's manufacturer has a poor DDC/CI implementation, or you are connecting through a docking station that does not properly support DDC/CI, some features may not work as expected. And we can do nothing about it. + +**Key Concepts:** + +| Term | Description | +|------|-------------| +| **VCP (Virtual Control Panel)** | Standardized codes for monitor settings | +| **MCCS (Monitor Command Control Set)** | VESA standard defining VCP codes | +| **Capabilities String** | Monitor-reported string describing supported features | + +**Common VCP Codes Used:** + +| VCP Code | Name | Description | +|----------|------|-------------| +| `0x10` | Brightness | Display luminance (0-100) | +| `0x12` | Contrast | Display contrast ratio (0-100) | +| `0x14` | Select Color Preset | Color temperature presets (sRGB, 5000K, 6500K, etc.) | +| `0x60` | Input Source | Active video input (HDMI, DP, USB-C, etc.) | +| `0x62` | Volume | Speaker/headphone volume (0-100) | + +--- + +### WMI (Windows Management Instrumentation) + +**WMI** is Microsoft's implementation of Web-Based Enterprise Management (WBEM), +providing a standardized interface for accessing management information in Windows. +For display control, WMI is primarily used for laptop internal displays that may not +support DDC/CI. + +--- + +## Architecture Overview + +### High-Level Component Architecture + +```mermaid +flowchart TB + subgraph PowerToys["PowerToys Application"] + Runner["Runner (PowerToys.exe)"] + SettingsUI["Settings UI (WinUI 3)"] + LightSwitch["LightSwitch Module"] + end + + subgraph PowerDisplayModule["PowerDisplay Module"] + ModuleInterface["Module Interface
(PowerDisplayModuleInterface.dll)"] + PowerDisplayApp["PowerDisplay App
(PowerToys.PowerDisplay.exe)"] + PowerDisplayLib["PowerDisplay.Lib
(Shared Library)"] + end + + subgraph External["External"] + Hardware["Display Hardware
(External + Internal)"] + Storage["Persistent Storage
(settings.json, profiles.json)"] + end + + Runner -->|"Loads DLL"| ModuleInterface + Runner -->|"Hotkey Events"| ModuleInterface + SettingsUI <-->|"Named Pipes"| Runner + SettingsUI -->|"Custom Actions
(Launch, ApplyProfile)"| ModuleInterface + + ModuleInterface <-->|"Windows Events
(Show/Toggle/Terminate)"| PowerDisplayApp + PowerDisplayApp -->|"RefreshMonitors Event"| SettingsUI + LightSwitch -->|"Theme Events
(Light/Dark)"| PowerDisplayApp + + PowerDisplayApp --> PowerDisplayLib + PowerDisplayLib -->|"DDC/CI (Dxva2.dll)"| Hardware + PowerDisplayLib -->|"WMI (WmiLight)"| Hardware + PowerDisplayLib -->|"ChangeDisplaySettingsEx"| Hardware + PowerDisplayApp <--> Storage + + style Runner fill:#e1f5fe + style SettingsUI fill:#e1f5fe + style LightSwitch fill:#e1f5fe + style ModuleInterface fill:#fff3e0 + style PowerDisplayApp fill:#fff3e0 + style PowerDisplayLib fill:#e8f5e9 + style Hardware fill:#f3e5f5 + style Storage fill:#fffde7 +``` + +This high-level view shows the module boundaries. See [Component Design](#component-design) +for internal structure details. + +--- + +### Project Structure + +``` +src/modules/powerdisplay/ +├── PowerDisplay.Lib/ # Core library (shared) +│ ├── Drivers/ +│ │ ├── DDC/ +│ │ │ ├── DdcCiController.cs # DDC/CI implementation +│ │ │ ├── DdcCiNative.cs # P/Invoke declarations & QueryDisplayConfig +│ │ │ ├── MonitorDiscoveryHelper.cs +│ │ │ └── PhysicalMonitorHandleManager.cs +│ │ ├── WMI/ +│ │ │ └── WmiController.cs # WMI implementation (WmiLight library) +│ │ ├── NativeConstants.cs # Win32 constants (VCP codes, etc.) +│ │ ├── NativeDelegates.cs # P/Invoke delegate types +│ │ ├── NativeStructures.cs # Win32 structures +│ │ └── PInvoke.cs # P/Invoke declarations +│ ├── Interfaces/ +│ │ ├── IMonitorController.cs # Controller abstraction +│ │ ├── IMonitorData.cs # Monitor data interface +│ │ └── IProfileService.cs # Profile service interface +│ ├── Models/ +│ │ ├── Monitor.cs # Runtime monitor data +│ │ ├── MonitorCapabilities.cs # Monitor capability flags +│ │ ├── MonitorOperationResult.cs # Operation result +│ │ ├── MonitorStateEntry.cs # Persisted monitor state +│ │ ├── MonitorStateFile.cs # State file schema +│ │ ├── PowerDisplayProfile.cs # Profile definition +│ │ ├── PowerDisplayProfiles.cs # Profile collection +│ │ ├── ProfileMonitorSetting.cs # Per-monitor profile settings +│ │ ├── ColorPresetItem.cs # Color preset UI item +│ │ ├── VcpCapabilities.cs # Parsed VCP capabilities +│ │ └── VcpFeatureValue.cs # VCP feature value (current/min/max) +│ ├── Serialization/ +│ │ └── ProfileSerializationContext.cs # JSON source generation +│ ├── Services/ +│ │ ├── DisplayRotationService.cs # Display rotation via ChangeDisplaySettingsEx +│ │ ├── MonitorStateManager.cs # State persistence (debounced save) and restore on startup +│ │ └── ProfileService.cs # Profile persistence +│ ├── Utils/ +│ │ ├── ColorTemperatureHelper.cs # Color temp utilities +│ │ ├── EventHelper.cs # Windows Event utilities +│ │ ├── MccsCapabilitiesParser.cs # DDC/CI capabilities parser +│ │ ├── MonitorFeatureHelper.cs # Monitor feature utilities +│ │ ├── MonitorMatchingHelper.cs # Profile-to-monitor matching +│ │ ├── MonitorValueConverter.cs # Value conversion utilities +│ │ ├── PnpIdHelper.cs # PnP manufacturer ID lookup +│ │ ├── ProfileHelper.cs # Profile helper utilities +│ │ ├── SimpleDebouncer.cs # Generic debouncer +│ │ └── VcpNames.cs # VCP code and value name lookup +│ └── PathConstants.cs # File path constants +│ +├── PowerDisplay/ # WinUI 3 application +│ ├── Assets/ # App icons and images +│ ├── Configuration/ +│ │ └── AppConstants.cs # Application constants +│ ├── Helpers/ +│ │ ├── DisplayChangeWatcher.cs # Monitor hot-plug detection (WinRT DeviceWatcher) +│ │ ├── MonitorManager.cs # Discovery orchestrator +│ │ ├── NativeEventWaiter.cs # Windows Event waiting +│ │ ├── ResourceLoaderInstance.cs # Resource loader singleton +│ │ ├── SettingsDeepLink.cs # Deep link to Settings UI +│ │ ├── TrayIconService.cs # System tray integration +│ │ ├── TypePreservation.cs # AOT type preservation +│ │ └── WindowHelper.cs # Window utilities +│ ├── PowerDisplayXAML/ +│ │ ├── App.xaml / App.xaml.cs # Application entry point +│ │ ├── MainWindow.xaml / .cs # Main UI window +│ │ ├── IdentifyWindow.xaml / .cs # Monitor identify overlay +│ │ └── MonitorIcon.xaml / .cs # Monitor icon control +│ ├── Serialization/ +│ │ └── JsonSourceGenerationContext.cs # JSON source generation +│ ├── Services/ +│ │ └── LightSwitchService.cs # LightSwitch theme change handling +│ ├── Strings/ # Localization resources (en-us) +│ ├── Telemetry/ +│ │ └── Events/ +│ │ └── PowerDisplayStartEvent.cs # Telemetry event +│ ├── ViewModels/ +│ │ ├── ColorTemperatureItem.cs # Color temperature dropdown item +│ │ ├── InputSourceItem.cs # Input source dropdown item +│ │ ├── MainViewModel.cs # Main VM (partial class) +│ │ ├── MainViewModel.Monitors.cs # Monitor discovery methods +│ │ ├── MainViewModel.Settings.cs # Settings persistence methods +│ │ └── MonitorViewModel.cs # Per-monitor VM +│ ├── GlobalUsings.cs # Global using directives +│ └── Program.cs # Application entry point +│ +├── PowerDisplay.Lib.UnitTests/ # Unit tests +│ ├── MccsCapabilitiesParserTests.cs +│ └── MonitorMatchingHelperTests.cs +│ +└── PowerDisplayModuleInterface/ # C++ DLL (module interface) + ├── dllmain.cpp # PowertoyModuleIface impl + ├── Constants.h # Module constants (event names, timeouts) + ├── resource.h # Resource definitions + ├── pch.h / pch.cpp # Precompiled headers + └── Trace.h / Trace.cpp # ETW telemetry tracing +``` + +--- + +## Component Design + +### PowerDisplay Module Internal Structure + +```mermaid +flowchart TB + subgraph ExternalInputs["External Inputs"] + ModuleInterface["Module Interface
(C++ DLL)"] + LightSwitch["LightSwitch Module"] + end + + subgraph WindowsEvents["Windows Events (IPC)"] + direction LR + ShowToggleEvents["Show/Toggle/Terminate
Events"] + ThemeChangedEvent["ThemeChanged
Events"] + end + + subgraph PowerDisplayModule["PowerDisplay Module"] + subgraph PowerDisplayApp["PowerDisplay App (WinUI 3)"] + MainViewModel + MonitorViewModel + MonitorManager + DisplayChangeWatcher["DisplayChangeWatcher
(Hot-Plug Detection)"] + LightSwitchService["LightSwitchService
(Theme Handler)"] + end + + subgraph PowerDisplayLib["PowerDisplay.Lib"] + subgraph Services + ProfileService + MonitorStateManager + DisplayRotationService + end + subgraph Drivers + DdcCiController + WmiController + end + subgraph Utils + PnpIdHelper["PnpIdHelper
(Manufacturer Names)"] + end + end + end + + subgraph Storage["Persistent Storage"] + SettingsJson[("settings.json")] + ProfilesJson[("profiles.json")] + MonitorStateJson[("monitor_state.json")] + end + + subgraph Hardware["Display Hardware"] + ExternalMonitor["External Monitor"] + LaptopDisplay["Laptop Display"] + end + + %% External to Windows Events + ModuleInterface -->|"SetEvent()"| ShowToggleEvents + LightSwitch -->|"SetEvent()"| ThemeChangedEvent + + %% Windows Events to App + ShowToggleEvents --> MainViewModel + ThemeChangedEvent --> LightSwitchService + + %% App internal + LightSwitchService -.->|"Get profile name"| MainViewModel + MainViewModel --> MonitorViewModel + MonitorViewModel --> MonitorManager + DisplayChangeWatcher -.->|"DisplayChanged event"| MainViewModel + + %% App to Lib services + MainViewModel --> ProfileService + MonitorViewModel --> MonitorStateManager + MonitorManager --> Drivers + MonitorManager --> DisplayRotationService + + %% Utils used during discovery + WmiController --> PnpIdHelper + + %% Services to Storage + ProfileService --> ProfilesJson + MonitorStateManager --> MonitorStateJson + + %% Drivers to Hardware + DdcCiController -->|"DDC/CI"| ExternalMonitor + WmiController -->|"WMI"| LaptopDisplay + DisplayRotationService -->|"ChangeDisplaySettingsEx"| ExternalMonitor + DisplayRotationService -->|"ChangeDisplaySettingsEx"| LaptopDisplay + + %% Force vertical layout: PowerDisplay.Lib above Storage/Hardware + PowerDisplayLib ~~~ Storage + PowerDisplayLib ~~~ Hardware + + %% Styling + style ExternalInputs fill:#e3f2fd,stroke:#1976d2 + style WindowsEvents fill:#fce4ec,stroke:#c2185b + style PowerDisplayModule fill:#fff8e1,stroke:#f57c00,stroke-width:2px + style PowerDisplayApp fill:#ffe0b2,stroke:#ef6c00 + style PowerDisplayLib fill:#c8e6c9,stroke:#388e3c + style Services fill:#a5d6a7,stroke:#2e7d32 + style Drivers fill:#ffccbc,stroke:#e64a19 + style Utils fill:#dcedc8,stroke:#689f38 + style Storage fill:#e1bee7,stroke:#8e24aa + style Hardware fill:#b2dfdb,stroke:#00897b +``` + +--- + +### DisplayChangeWatcher - Monitor Hot-Plug Detection + +The `DisplayChangeWatcher` component provides automatic detection of monitor connect/disconnect events using the WinRT DeviceWatcher API. + +**Key Features:** +- Uses `DisplayMonitor.GetDeviceSelector()` to watch for display device changes +- Implements 1-second debouncing to coalesce rapid connect/disconnect events +- Triggers `DisplayChanged` event to notify `MainViewModel` for monitor list refresh +- Runs continuously after initial monitor discovery completes + +**Implementation Details:** +```csharp +// Device selector for display monitors +string selector = DisplayMonitor.GetDeviceSelector(); +_deviceWatcher = DeviceInformation.CreateWatcher(selector); + +// Events monitored +_deviceWatcher.Added += OnDeviceAdded; // New monitor connected +_deviceWatcher.Removed += OnDeviceRemoved; // Monitor disconnected +_deviceWatcher.Updated += OnDeviceUpdated; // Monitor properties changed +``` + +**Debouncing Strategy:** +- Each device change event schedules a `DisplayChanged` event after 1 second +- Subsequent events within the debounce window cancel the previous timer +- This prevents excessive refreshes when multiple monitors change simultaneously + +--- + +### DDC/CI and WMI Interaction Architecture + +```mermaid +flowchart TB + subgraph Application["Application Layer"] + MM["MonitorManager"] + end + + subgraph Abstraction["Abstraction Layer"] + IMC["IMonitorController Interface"] + end + + subgraph Controllers["Controller Implementations"] + DDC["DdcCiController"] + WMI["WmiController"] + end + + subgraph DDCStack["DDC/CI Stack"] + DDCNative["DdcCiNative
(P/Invoke)"] + PhysicalMonitorMgr["PhysicalMonitorHandleManager"] + MonitorDiscovery["MonitorDiscoveryHelper"] + CapParser["MccsCapabilitiesParser"] + + subgraph Win32["Win32 APIs"] + User32["User32.dll
EnumDisplayMonitors
GetMonitorInfo"] + Dxva2["Dxva2.dll
GetVCPFeature
SetVCPFeature
Capabilities"] + end + end + + subgraph WMIStack["WMI Stack"] + WmiLight["WmiLight Library
(Native AOT compatible,
NuGet package)"] + PnpHelper["PnpIdHelper
(Manufacturer name lookup)"] + + subgraph WMIClasses["WMI Classes (root\\WMI)"] + WmiMonBright["WmiMonitorBrightness"] + WmiMonBrightMethods["WmiMonitorBrightnessMethods"] + end + end + + subgraph Hardware["Hardware Layer"] + ExtMon["External Monitor
(DDC/CI capable)"] + LaptopMon["Laptop Display
(WMI only)"] + end + + MM --> IMC + IMC -.-> DDC + IMC -.-> WMI + + DDC --> DDCNative + DDC --> PhysicalMonitorMgr + DDC --> MonitorDiscovery + DDC --> CapParser + + DDCNative --> User32 + DDCNative --> Dxva2 + MonitorDiscovery --> User32 + PhysicalMonitorMgr --> Dxva2 + + Dxva2 -->|"I2C/DDC"| ExtMon + + WMI --> WmiLight + WMI --> PnpHelper + WmiLight --> WmiMonBright + WmiLight --> WmiMonBrightMethods + + WmiMonBrightMethods -->|"WMI Provider"| LaptopMon + + style IMC fill:#bbdefb + style DDC fill:#c8e6c9 + style WMI fill:#ffccbc +``` + +### IMonitorController Interface Methods + +```mermaid +classDiagram + class IMonitorController { + <> + +Name: string + +DiscoverMonitorsAsync(cancellationToken) IEnumerable~Monitor~ + +GetBrightnessAsync(monitor, cancellationToken) VcpFeatureValue + +SetBrightnessAsync(monitor, brightness, cancellationToken) MonitorOperationResult + +SetContrastAsync(monitor, contrast, cancellationToken) MonitorOperationResult + +SetVolumeAsync(monitor, volume, cancellationToken) MonitorOperationResult + +GetColorTemperatureAsync(monitor, cancellationToken) VcpFeatureValue + +SetColorTemperatureAsync(monitor, vcpValue, cancellationToken) MonitorOperationResult + +GetInputSourceAsync(monitor, cancellationToken) VcpFeatureValue + +SetInputSourceAsync(monitor, inputSource, cancellationToken) MonitorOperationResult + +Dispose() + } + + class DdcCiController { + -_handleManager: PhysicalMonitorHandleManager + -_discoveryHelper: MonitorDiscoveryHelper + +Name: "DDC/CI Monitor Controller" + +DiscoverMonitorsAsync() + +GetBrightnessAsync(monitor) + +SetBrightnessAsync(monitor, brightness) + +SetContrastAsync(monitor, contrast) + +SetVolumeAsync(monitor, volume) + +GetColorTemperatureAsync(monitor) + +SetColorTemperatureAsync(monitor, colorTemperature) + +GetInputSourceAsync(monitor) + +SetInputSourceAsync(monitor, inputSource) + +GetCapabilitiesStringAsync(monitor) string + -GetVcpFeatureAsync(monitor, vcpCode) + -CollectCandidateMonitorsAsync() + -FetchCapabilitiesInParallelAsync() + -GetPhysicalMonitorsWithRetryAsync() + } + + class WmiController { + +Name: "WMI Monitor Controller" + +DiscoverMonitorsAsync() + +GetBrightnessAsync(monitor) + +SetBrightnessAsync(monitor, brightness) + +SetContrastAsync(monitor, contrast) + +SetVolumeAsync(monitor, volume) + +GetColorTemperatureAsync(monitor) + +SetColorTemperatureAsync(monitor, colorTemperature) + +GetInputSourceAsync(monitor) + +SetInputSourceAsync(monitor, inputSource) + -ExtractHardwareIdFromInstanceName() + -GetMonitorDisplayInfoByHardwareId() + } + + IMonitorController <|.. DdcCiController + IMonitorController <|.. WmiController +``` + +--- + +### Why WmiLight Instead of System.Management + +PowerDisplay uses the [WmiLight](https://github.com/MartinKuschnik/WmiLight) NuGet package +for WMI operations instead of the built-in `System.Management` namespace. This decision was +driven by several technical requirements: + +#### Native AOT Compatibility + +PowerDisplay is built with Native AOT (Ahead-of-Time compilation) enabled for improved startup +performance and reduced memory footprint. The standard `System.Management` namespace is **not +compatible with Native AOT** because it relies heavily on runtime reflection and COM interop +patterns that cannot be statically analyzed. + +WmiLight provides Native AOT support since version 5.0.0, making it the appropriate choice for +AOT-compiled applications. + +```xml + + + true + + + + +``` + +#### Memory Leak Prevention + +The `System.Management` implementation has a known issue where it leaks memory on each WMI +operation. While this might be acceptable for short-lived applications, PowerDisplay runs as +a long-running background process that may perform frequent WMI queries (e.g., polling +brightness levels, responding to theme changes). WmiLight addresses this memory leak issue. + +#### Lightweight API + +WmiLight provides a simpler, more lightweight API compared to `System.Management`: + +```csharp +// WmiLight - Simple and direct +using (var connection = new WmiConnection(@"root\WMI")) +{ + var results = connection.CreateQuery("SELECT * FROM WmiMonitorBrightness"); + foreach (var obj in results) + { + var brightness = obj.GetPropertyValue("CurrentBrightness"); + } +} + +// System.Management - More verbose +using (var searcher = new ManagementObjectSearcher(@"root\WMI", "SELECT * FROM WmiMonitorBrightness")) +{ + foreach (ManagementObject obj in searcher.Get()) + { + var brightness = (byte)obj["CurrentBrightness"]; + } +} +``` + +#### Comparison Summary + +| Aspect | System.Management | WmiLight | +|--------|-------------------|----------| +| **Native AOT Support** | ❌ Not supported | ✅ Supported (v5.0.0+) | +| **Memory Leaks** | ⚠️ Leaks on remote operations | ✅ No known leaks | +| **API Complexity** | More verbose | Simpler, lighter | +| **Long-running Services** | Not recommended | ✅ Recommended | +| **Static Linking** | ❌ Not available | ✅ Optional (`PublishWmiLightStaticallyLinked`) | + +#### References + +- [WmiLight GitHub Repository](https://github.com/MartinKuschnik/WmiLight) +- [WmiLight NuGet Package](https://www.nuget.org/packages/WmiLight) + +--- + +### Why We Need an MCCS Capabilities String Parser + +DDC/CI monitors report their supported features via a **capabilities string** - a structured +text format defined by the VESA MCCS (Monitor Control Command Set) standard. This string +tells PowerDisplay which VCP codes the monitor supports and what values are valid for each. + +#### Example Capabilities String + +``` +(prot(monitor)type(lcd)model(PD3220U)cmds(01 02 03 07)vcp(10 12 14(04 05 06) 60(11 12 0F))mccs_ver(2.2)) +``` + +This string encodes: +- **Protocol**: monitor +- **Type**: LCD display +- **Model**: PD3220U +- **Supported commands**: 0x01, 0x02, 0x03, 0x07 +- **VCP codes**: 0x10 (brightness), 0x12 (contrast), 0x14 (color preset with values 4,5,6), 0x60 (input source with values 0x11, 0x12, 0x0F) +- **MCCS version**: 2.2 + +#### Why Parse It? + +| Use Case | How Parser Helps | +|----------|------------------| +| **Feature Detection** | Determine if monitor supports contrast, volume, color temperature, input switching | +| **Input Source Dropdown** | Extract valid input source values (e.g., HDMI-1=0x11, DP=0x0F) for UI dropdown | +| **Color Preset List** | Extract supported color presets (e.g., sRGB, 5000K, 6500K) | +| **Diagnostics** | Display raw VCP codes in Settings UI for troubleshooting | +| **PIP/PBP Support** | Parse window capabilities for Picture-in-Picture features | + +#### Why Not Use Regex? + +The MCCS capabilities string format has **nested parentheses** that regex cannot reliably handle: + +``` +vcp(10 12 14(04 05 06) 60(11 12 0F)) + ^^^^^^^^^^^^ nested values +``` + +A recursive descent parser properly handles: +- Nested parentheses at arbitrary depth +- Variable whitespace (some monitors use `01 02 03`, others use `010203`) +- Optional outer parentheses (some monitors omit them) +- Unknown segments (graceful skip without failing) + +#### Implementation + +PowerDisplay implements a **zero-allocation recursive descent parser** using `ref struct` and +`ReadOnlySpan` for optimal performance during monitor discovery. + +```csharp +// Usage in DdcCiController +var result = MccsCapabilitiesParser.Parse(capabilitiesString); +if (result.IsValid) +{ + monitor.VcpCapabilitiesInfo = result.Capabilities; + // Now we know which features this monitor supports +} +``` + +> **Detailed Design:** See [mccsParserDesign.md](./mccsParserDesign.md) for the complete +> parser architecture, grammar definition, and implementation details. + +--- + +### Monitor Identification: Handles, IDs, and Names + +Understanding how Windows identifies monitors is critical for PowerDisplay's operation. +Different Windows APIs use different identifiers, and PowerDisplay must correlate these +to provide a unified view across DDC/CI and WMI subsystems. + +#### Windows Display Subsystem Overview + +```mermaid +flowchart TB + subgraph WindowsAPIs["Windows Display APIs"] + EnumDisplayMonitors["EnumDisplayMonitors
(User32.dll)"] + QueryDisplayConfig["QueryDisplayConfig
(User32.dll)"] + GetPhysicalMonitors["GetPhysicalMonitorsFromHMONITOR
(Dxva2.dll)"] + WmiMonitor["WMI root\\WMI
(WmiLight)"] + end + + subgraph Identifiers["Monitor Identifiers"] + HMONITOR["HMONITOR
(Logical Monitor Handle)"] + GdiDeviceName["GDI Device Name
(e.g., \\\\.\\DISPLAY1)"] + PhysicalHandle["Physical Monitor Handle
(IntPtr for DDC/CI)"] + DevicePath["Device Path
(Unique per target)"] + HardwareId["Hardware ID
(e.g., DEL41B4)"] + InstanceName["WMI Instance Name
(e.g., DISPLAY\\BOE0900\\...)"] + MonitorNumber["Monitor Number
(1-based, matches Windows Settings)"] + end + + EnumDisplayMonitors --> HMONITOR + HMONITOR --> GdiDeviceName + GetPhysicalMonitors --> PhysicalHandle + + QueryDisplayConfig --> GdiDeviceName + QueryDisplayConfig --> DevicePath + QueryDisplayConfig --> HardwareId + QueryDisplayConfig --> MonitorNumber + + WmiMonitor --> InstanceName + InstanceName --> HardwareId + + style HMONITOR fill:#e3f2fd + style GdiDeviceName fill:#fff3e0 + style PhysicalHandle fill:#c8e6c9 + style DevicePath fill:#f3e5f5 + style HardwareId fill:#ffccbc + style InstanceName fill:#ffe0b2 + style MonitorNumber fill:#b2dfdb +``` + +#### Identifier Definitions + +| Identifier | Source | Format | Example | Scope | +|------------|--------|--------|---------|-------| +| **HMONITOR** | `EnumDisplayMonitors` | `IntPtr` | `0x00010001` | Logical monitor (may represent multiple physical monitors in clone mode) | +| **GDI Device Name** | `GetMonitorInfo` / `QueryDisplayConfig` | String | `\\.\DISPLAY1` | Adapter output; multiple targets can share same GDI name in mirror mode | +| **Physical Monitor Handle** | `GetPhysicalMonitorsFromHMONITOR` | `IntPtr` | `0x00000B14` | DDC/CI communication handle; valid for `GetVCPFeature` / `SetVCPFeature` | +| **Device Path** | `QueryDisplayConfig` | String | `\\?\DISPLAY#DEL41B4#5&12a3b4c&0&UID123#{...}` | Unique per target; used as primary key in `MonitorDisplayInfo` | +| **Hardware ID** | EDID (via `QueryDisplayConfig`) | String | `DEL41B4` | Manufacturer (3-char PnP ID) + Product Code (4-char hex); identifies monitor model | +| **WMI Instance Name** | `WmiMonitorBrightness` | String | `DISPLAY\BOE0900\4&10fd3ab1&0&UID265988_0` | WMI object identifier; contains hardware ID in second segment | +| **Monitor Number** | `QueryDisplayConfig` path index | Integer | `1`, `2`, `3` | 1-based; matches Windows Settings → Display → "Identify" feature | + +#### DDC/CI Monitor Discovery Flow + +```mermaid +sequenceDiagram + participant App as PowerDisplay + participant Enum as EnumDisplayMonitors + participant Info as GetMonitorInfo + participant QDC as QueryDisplayConfig + participant Phys as GetPhysicalMonitors + participant DDC as DDC/CI (I2C) + + App->>Enum: EnumDisplayMonitors(callback) + Enum-->>App: HMONITOR handles + + loop For each HMONITOR + App->>Info: GetMonitorInfo(hMonitor) + Info-->>App: GDI Device Name (e.g., "\\.\DISPLAY1") + + App->>Phys: GetPhysicalMonitorsFromHMONITOR(hMonitor) + Phys-->>App: Physical Monitor Handle(s) + Description + end + + App->>QDC: QueryDisplayConfig(QDC_ONLY_ACTIVE_PATHS) + QDC-->>App: MonitorDisplayInfo[] (DevicePath, GdiDeviceName, HardwareId, MonitorNumber) + + Note over App: Match Physical Handles to MonitorDisplayInfo
using GDI Device Name + + loop For each Physical Handle + App->>DDC: GetCapabilitiesStringLength(handle) + DDC-->>App: Capabilities length + App->>DDC: CapabilitiesRequestAndCapabilitiesReply(handle) + DDC-->>App: Capabilities string (MCCS format) + end + + Note over App: Create Monitor objects with:
- Handle (Physical Monitor Handle)
- MonitorNumber (from QueryDisplayConfig)
- GdiDeviceName (for rotation APIs) +``` + +#### WMI Monitor Discovery Flow + +```mermaid +sequenceDiagram + participant App as PowerDisplay + participant WMI as WmiLight + participant QDC as QueryDisplayConfig + participant PnP as PnpIdHelper + + App->>WMI: Query WmiMonitorBrightness + WMI-->>App: InstanceName, CurrentBrightness + + Note over App: Extract HardwareId from InstanceName
"DISPLAY\BOE0900\..." → "BOE0900" + + App->>QDC: GetAllMonitorDisplayInfo() + QDC-->>App: MonitorDisplayInfo[] (keyed by DevicePath) + + Note over App: Match WMI monitor to QueryDisplayConfig
by comparing HardwareId + + App->>PnP: GetBuiltInDisplayName("BOE0900") + PnP-->>App: "BOE Built-in Display" + + Note over App: Create Monitor objects with:
- InstanceName (for WMI queries)
- MonitorNumber (from QueryDisplayConfig)
- GdiDeviceName (for rotation APIs) +``` + +#### Key Relationships + +##### GDI Device Name ↔ Physical Monitors + +```mermaid +flowchart TB + HMON["HMONITOR (Logical)"] + + HMON --> GDI["GetMonitorInfo()
→ GDI Device Name
\.DISPLAY1"] + HMON --> GetPhys["GetPhysicalMonitorsFromHMONITOR()"] + + GetPhys --> PM0["Physical Monitor 0
Handle: 0x0B14
Desc: Dell U2722D"] + GetPhys --> PM1["Physical Monitor 1
Handle: 0x0B18
Desc: Dell U2722D
Mirror mode"] + + style HMON fill:#e3f2fd + style PM0 fill:#fff3e0 + style PM1 fill:#fff3e0 +``` + +In **mirror/clone mode**, multiple physical monitors share the same GDI device name. +QueryDisplayConfig returns multiple paths with the same `GdiDeviceName` but different +`DevicePath` values, allowing us to distinguish them. + +##### DisplayPort Daisy Chain (MST - Multi-Stream Transport) + +**Daisy chaining** allows multiple monitors to be connected in series through a single +DisplayPort output using MST (Multi-Stream Transport) technology. This creates unique +challenges for monitor identification. + +```mermaid +flowchart LR + GPU["GPU
(Single DP Port)"] + MonA["Monitor A
(MST Hub)"] + MonB["Monitor B
(End)"] + + GPU -->|"DP"| MonA -->|"DP"| MonB + + subgraph Result["Result: Multiple Logical Displays"] + D1["DISPLAY1"] + D2["DISPLAY2"] + end + + GPU -.-> Result + + style GPU fill:#bbdefb + style MonA fill:#c8e6c9 + style MonB fill:#c8e6c9 + style Result fill:#fff3e0 +``` + +**How Windows Handles MST:** + +| Aspect | Behavior | +|--------|----------| +| **HMONITOR** | Each daisy-chained monitor gets its own HMONITOR | +| **GDI Device Name** | Each monitor gets a unique GDI name (e.g., `\\.\DISPLAY1`, `\\.\DISPLAY2`) | +| **Physical Monitor Handle** | Each monitor has its own physical handle for DDC/CI | +| **Device Path** | Unique for each monitor in the chain | +| **Hardware ID** | Different if monitors are different models; same if identical models | + +**MST vs Clone Mode Comparison:** + +| Property | MST Daisy Chain (Extended Desktop) | Clone/Mirror Mode | +|----------|-----------------------------------|-------------------| +| **HMONITOR** | Separate per monitor (HMONITOR_1, HMONITOR_2, ...) | Shared (single HMONITOR_1) | +| **GDI Device Name** | Unique per monitor (`\\.\DISPLAY1`, `\\.\DISPLAY2`, ...) | Shared (`\\.\DISPLAY1`) | +| **Physical Handle** | One per HMONITOR (A, B, C) | Multiple per HMONITOR (A, B) | +| **DevicePath** | Unique per monitor (unique1, unique2, ...) | Unique per monitor (unique1, unique2) | +| **Behavior** | Each monitor = independent logical display | Multiple monitors share same logical display | + +**PowerDisplay Handling of MST:** + +1. **Discovery**: `EnumDisplayMonitors` returns separate HMONITOR for each MST monitor +2. **Physical Handles**: `GetPhysicalMonitorsFromHMONITOR` returns one handle per HMONITOR +3. **Matching**: QueryDisplayConfig provides unique DevicePath for each MST target +4. **DDC/CI**: Each monitor in the chain can be controlled independently via its handle + +**Identifying Same-Model Monitors in Daisy Chain:** + +When multiple identical monitors are daisy-chained (same Hardware ID), PowerDisplay +distinguishes them using: + +- **MonitorNumber**: Different path index in QueryDisplayConfig (1, 2, 3...) +- **DevicePath**: Unique system-generated path for each target +- **Monitor.Id**: Format `DDC_{HardwareId}_{MonitorNumber}` ensures uniqueness + +Example with two identical Dell U2722D monitors: + +| Monitor | Id | MonitorNumber | +|---------|-----|---------------| +| Monitor 1 | `DDC_DEL41B4_1` | 1 | +| Monitor 2 | `DDC_DEL41B4_2` | 2 | + +##### Connection Mode Summary + +| Mode | HMONITOR | GDI Device Name | Physical Handles | Use Case | +|------|----------|-----------------|------------------|----------| +| **Standard** (separate cables) | 1 per monitor | Unique per monitor | 1 per HMONITOR | Most common setup | +| **Clone/Mirror** | 1 shared | Shared | Multiple per HMONITOR | Presentation, duplication | +| **MST Daisy Chain** | 1 per monitor | Unique per monitor | 1 per HMONITOR | Reduced cable clutter | +| **USB-C/Thunderbolt Hub** | 1 per monitor | Unique per monitor | 1 per HMONITOR | Laptop docking | + +**Key Insight**: From PowerDisplay's perspective, MST daisy chain and standard multi-cable +setups behave identically - each monitor appears as an independent display with unique +identifiers. Only clone/mirror mode requires special handling due to shared HMONITOR/GDI names. + +##### Hardware ID Composition + +```mermaid +flowchart TB + HardwareId["Hardware ID: DEL41B4"] + + HardwareId --> PnpId["DEL
PnP Manufacturer ID
3 chars, EDID bytes 8-9"] + HardwareId --> ProductCode["41B4
Product Code
4 hex chars, EDID bytes 10-11"] + + style HardwareId fill:#fff3e0 + style PnpId fill:#c8e6c9 + style ProductCode fill:#bbdefb +``` + +The **PnP Manufacturer ID** is a 3-character code assigned by UEFI Forum. +Common laptop display manufacturers: + +| PnP ID | Manufacturer | +|--------|--------------| +| `BOE` | BOE Technology | +| `LGD` | LG Display | +| `AUO` | AU Optronics | +| `CMN` | Chi Mei Innolux | +| `SDC` | Samsung Display | +| `SHP` | Sharp | +| `LEN` | Lenovo | +| `DEL` | Dell | + +##### WMI Instance Name Parsing + +```mermaid +flowchart TB + InstanceName["WMI InstanceName:
DISPLAY\BOE0900\4#amp;10fd3ab1#amp;0#amp;UID265988_0"] + + InstanceName --> Seg1["Segment 1: DISPLAY
Constant prefix"] + InstanceName --> Seg2["Segment 2: BOE0900
Hardware ID
Used for matching with QueryDisplayConfig"] + InstanceName --> Seg3["Segment 3: Device instance
4#amp;10fd3ab1#amp;0#amp;UID265988_0"] + + style InstanceName fill:#fff3e0 + style Seg1 fill:#e0e0e0 + style Seg2 fill:#c8e6c9 + style Seg3 fill:#e0e0e0 +``` + +##### Monitor Number (Windows Display Settings) + +The `MonitorNumber` in PowerDisplay corresponds exactly to the number shown in: +- Windows Settings → System → Display → "Identify" button +- The number overlay that appears on each display + +This is derived from the **path index** in `QueryDisplayConfig`: +- `paths[0]` → Monitor 1 +- `paths[1]` → Monitor 2 +- etc. + +#### Display Rotation and GDI Device Name + +The `ChangeDisplaySettingsEx` API requires the **GDI Device Name** to target a specific display: + +```cpp +// Correct: Target specific display by GDI name +ChangeDisplaySettingsEx("\\.\DISPLAY2", &devMode, NULL, 0, NULL); + +// Wrong: NULL affects primary display only +ChangeDisplaySettingsEx(NULL, &devMode, NULL, 0, NULL); +``` + +PowerDisplay stores `GdiDeviceName` in each `Monitor` object specifically for rotation operations. + +#### Cross-Reference Summary + +| PowerDisplay Property | DDC/CI Source | WMI Source | +|-----------------------|---------------|------------| +| `Monitor.Id` | `"DDC_{HardwareId}_{MonitorNumber}"` | `"WMI_{HardwareId}_{MonitorNumber}"` | +| `Monitor.Handle` | Physical Monitor Handle | N/A (uses InstanceName) | +| `Monitor.InstanceName` | N/A | WMI InstanceName | +| `Monitor.GdiDeviceName` | QueryDisplayConfig | QueryDisplayConfig | +| `Monitor.MonitorNumber` | QueryDisplayConfig path index | QueryDisplayConfig (matched by HardwareId) | +| `Monitor.Name` | EDID FriendlyName or Description | PnpIdHelper.GetBuiltInDisplayName() | + +--- + +### Settings UI and PowerDisplay Interaction Architecture + +```mermaid +flowchart LR + subgraph SettingsUI["Settings UI Process"] + direction TB + Page["PowerDisplayPage.xaml"] + VM["PowerDisplayViewModel"] + Page --> VM + end + + subgraph Runner["Runner Process"] + direction TB + Exe["PowerToys.exe"] + Pipe["Named Pipe IPC"] + Module["PowerDisplayModuleInterface.dll"] + Pipe --> Exe --> Module + end + + subgraph PDApp["PowerDisplay Process"] + direction TB + MainVM["MainViewModel"] + Events["Event Listeners
Refresh / Profile"] + Events --> MainVM + end + + subgraph Storage["File System"] + direction TB + Settings[("settings.json")] + Profiles[("profiles.json")] + end + + %% Main flow: Settings UI → Runner → PowerDisplay + VM -->|"IPC Message"| Pipe + Module -->|"SetEvent()"| Events + + %% File access + VM <-.->|"Read/Write"| Settings + VM <-.->|"Read/Write"| Profiles + MainVM <-.->|"Read"| Settings + MainVM <-.->|"Read/Write"| Profiles + + style SettingsUI fill:#e3f2fd + style Runner fill:#fff3e0 + style PDApp fill:#e8f5e9 + style Storage fill:#fffde7 +``` + +**Data Models (in Settings.UI.Library):** + +| Model | Purpose | +|-------|---------| +| `PowerDisplaySettings` | Main settings container with properties | +| `MonitorInfo` | Per-monitor settings displayed in Settings UI (includes feature visibility flags like `EnableColorTemperature`) | + +### Windows Events for IPC + +Event names use fixed GUID suffixes to ensure uniqueness (defined in `shared_constants.h`). + +| Constant | Direction | Purpose | +|----------|-----------|---------| +| `TOGGLE_POWER_DISPLAY_EVENT` | Runner → App | Toggle visibility | +| `TERMINATE_POWER_DISPLAY_EVENT` | Runner → App | Terminate process | +| `REFRESH_POWER_DISPLAY_MONITORS_EVENT` | Settings → App | Refresh monitor list | +| `SETTINGS_UPDATED_POWER_DISPLAY_EVENT` | Settings → App | Notify settings changed (feature visibility, tray icon) | +| `LightSwitchLightThemeEventName` | LightSwitch → App | Apply light mode profile | +| `LightSwitchDarkThemeEventName` | LightSwitch → App | Apply dark mode profile | + +**Profile Application via Named Pipe IPC:** + +Profile application from Settings UI uses Named Pipe IPC (via Runner's `call_custom_action`) instead of +Windows Events. When the user clicks "Apply" on a profile in Settings UI, the message is sent through +the Runner to the Module Interface, which forwards it to PowerDisplay.exe via Named Pipe. + +**Event Name Format:** `Local\PowerToysPowerDisplay-{EventType}-{GUID}` + +Example: `Local\PowerToysPowerDisplay-ToggleEvent-5f1a9c3e-7d2b-4e8f-9a6c-3b5d7e9f1a2c` + +--- + +### LightSwitch Profile Integration Architecture + +```mermaid +flowchart TB + subgraph LightSwitchModule["LightSwitch Module (C++)"] + StateManager["LightSwitchStateManager"] + ThemeEval["Theme Evaluation
(Time/System)"] + LightSwitchSettings["LightSwitchSettings"] + NotifyPD["NotifyPowerDisplay(isLight)"] + end + + subgraph PowerDisplayModule["PowerDisplay Module (C#)"] + subgraph App["PowerDisplay App"] + EventWaiter["NativeEventWaiter
(Background Thread)"] + LightSwitchSvc["LightSwitchService
(Static Helper)"] + MainViewModel["MainViewModel"] + end + + ProfileService["ProfileService"] + MonitorVMs["MonitorViewModels"] + Controllers["IMonitorController"] + end + + subgraph WindowsEvents["Windows Events"] + LightEvent["Local\\PowerToys_LightSwitch_LightTheme"] + DarkEvent["Local\\PowerToys_LightSwitch_DarkTheme"] + end + + subgraph FileSystem["File System"] + LSSettingsJson["LightSwitch/settings.json
{lightProfile, darkProfile}"] + PDProfilesJson["PowerDisplay/profiles.json
{profiles: [...]}"] + end + + subgraph Hardware["Hardware"] + Monitors["Connected Monitors"] + end + + %% LightSwitch flow + ThemeEval -->|"Time boundary
or manual"| StateManager + StateManager --> LightSwitchSettings + StateManager --> NotifyPD + NotifyPD -->|"isLight=true"| LightEvent + NotifyPD -->|"isLight=false"| DarkEvent + + %% PowerDisplay flow - theme determined from event + LightEvent -->|"Event signaled"| EventWaiter + DarkEvent -->|"Event signaled"| EventWaiter + EventWaiter -->|"isLightMode"| LightSwitchSvc + LightSwitchSvc -->|"GetProfileForTheme()"| LSSettingsJson + LightSwitchSvc -->|"Profile name"| MainViewModel + MainViewModel -->|"LoadProfiles()"| ProfileService + ProfileService <--> PDProfilesJson + MainViewModel -->|"ApplyProfileAsync()"| MonitorVMs + MonitorVMs --> Controllers + Controllers --> Monitors + + style LightSwitchModule fill:#ffccbc + style PowerDisplayModule fill:#c8e6c9 + style App fill:#a5d6a7 + style WindowsEvents fill:#e3f2fd + style FileSystem fill:#fffde7 +``` + +### LightSwitch Settings JSON Structure + +```json +{ + "properties": { + "apply_monitor_settings": { "value": true }, + "enable_light_mode_profile": { "value": true }, + "light_mode_profile": { "value": "Productivity" }, + "enable_dark_mode_profile": { "value": true }, + "dark_mode_profile": { "value": "Night Mode" } + } +} +``` + +--- + +## Data Flow and Communication + +### Monitor Discovery Flow + +```mermaid +flowchart TB + Start([Start Discovery]) + Start --> MM["MonitorManager.DiscoverMonitorsAsync()"] + + MM --> DDC["DdcCiController.DiscoverMonitorsAsync()"] + MM --> WMI["WmiController.DiscoverMonitorsAsync()"] + + DDC --> Merge["Merge Results"] + WMI --> Merge + + Merge --> Sort["Sort by MonitorNumber"] + Sort --> Update["UpdateMonitorList()"] + Update --> Check{"RestoreSettingsOnStartup?"} + Check -->|Yes| Restore["RestoreMonitorSettingsAsync()
(Set hardware values)"] + Check -->|No| Done + Restore --> Done([Discovery Complete]) + + style Start fill:#e8f5e9 + style Done fill:#e8f5e9 + style DDC fill:#e3f2fd + style WMI fill:#fff3e0 + style Restore fill:#fff9c4 +``` + +> **Note:** DDC/CI and WMI discovery run in parallel via `Task.WhenAll`. +> +> **Settings Restore:** When `RestoreSettingsOnStartup` is enabled, `RestoreMonitorSettingsAsync()` is called +> after monitor discovery to restore saved brightness, contrast, color temperature, and volume values +> to the hardware. The UI remains in "scanning" state until restore completes. + +#### DDC/CI Discovery (Three-Phase Approach) + +**Phase 1: Collect Candidates** + +```mermaid +flowchart LR + QDC["QueryDisplayConfig"] --> Match["Match by GDI Name"] + Enum["EnumDisplayMonitors"] --> GetPhys["GetPhysicalMonitors"] --> Match + Match --> Candidates["CandidateMonitor List"] + + style QDC fill:#e3f2fd + style Enum fill:#e3f2fd +``` + +**Phase 2: Fetch Capabilities (Parallel)** + +```mermaid +flowchart LR + Candidates["CandidateMonitor List"] --> Fetch["Task.WhenAll:
FetchCapabilities
~4s per monitor via I2C"] + Fetch --> Results["DdcCiValidationResult Array"] + + style Fetch fill:#fff3e0 +``` + +**Phase 3: Create Monitors** + +```mermaid +flowchart LR + Results["Validation Results"] --> Check{"IsValid?"} + Check -->|Yes| Create["Create Monitor"] + Create --> Init["Initialize VCP Values:
Brightness, ColorTemp, InputSource"] + Init --> Add["Add to List"] + Check -->|No| Skip([Skip]) + + style Create fill:#e8f5e9 + style Init fill:#e8f5e9 +``` + +#### WMI Discovery + +```mermaid +flowchart LR + Query["Query WmiMonitorBrightness"] --> Extract["Extract HardwareId
from InstanceName"] + QDC["QueryDisplayConfig"] --> Match["Match by HardwareId"] + Extract --> Match + Match --> Name["Get Display Name
via PnpIdHelper"] + Name --> Create["Create Monitor
Brightness + WMI"] + + style Query fill:#fff3e0 + style Create fill:#fff3e0 +``` + +#### Key Differences + +| Aspect | DDC/CI | WMI | +|--------|--------|-----| +| **Target** | External monitors | Internal laptop displays | +| **Capabilities** | Full VCP support (brightness, contrast, volume, color temp, input) | Brightness only | +| **Discovery** | Three-phase with parallel I2C fetching | Single WMI query | +| **Initialization** | Reads current values for all supported VCP codes | Brightness from query result | +| **Performance** | ~4s per monitor (I2C), parallelized | Fast (~100ms total) | + +--- + +## Sequence Diagrams + +### Sequence: Modifying Color Temperature in Flyout UI + +Color temperature adjustment is now handled directly in the PowerDisplay Flyout UI, +providing a more responsive user experience without requiring IPC round-trips to Settings UI. + +```mermaid +sequenceDiagram + participant User + participant Flyout as MainWindow (Flyout) + participant MonitorVM as MonitorViewModel + participant MonitorManager + participant Controller as DdcCiController + participant StateManager as MonitorStateManager + participant Monitor as Physical Monitor + + User->>Flyout: Opens PowerDisplay flyout
(via hotkey or tray icon) + + Note over Flyout: Color temperature switcher visible
(if enabled in Settings) + + User->>Flyout: Selects color temperature preset
from dropdown (e.g., 6500K) + + Flyout->>MonitorVM: ColorTemperatureListView_SelectionChanged + MonitorVM->>MonitorVM: SetColorTemperatureAsync(vcpValue) + + MonitorVM->>MonitorManager: SetColorTemperatureAsync(monitor, vcpValue) + + MonitorManager->>Controller: SetColorTemperatureAsync(monitor, vcpValue) + Controller->>Controller: SetVcpFeatureAsync(VcpCodeColorTemperature) + Controller->>Monitor: SetVCPFeature(0x14, vcpValue) + Monitor-->>Controller: OK + + Controller-->>MonitorManager: MonitorOperationResult.Success + MonitorManager-->>MonitorVM: Success + + MonitorVM->>MonitorVM: RefreshAvailableColorPresets() + Note over MonitorVM: Regenerate ColorTemperatureItem list
with updated IsSelected flags + + MonitorVM->>StateManager: UpdateMonitorParameter("ColorTemperature", vcpValue) + + Note over StateManager: Debounced save (2 seconds) + StateManager->>StateManager: Schedule file write + + Note over StateManager: After 2s idle + StateManager->>StateManager: SaveToFile(monitor_state.json) + + Note over MonitorVM: UI updates to show
selected preset with checkmark +``` + +**Color Temperature Selection UI:** + +The color temperature switcher displays a list of available presets (e.g., 5000K, 6500K, sRGB). Each preset +shows a checkmark icon when selected. The `ColorTemperatureItem` class stores `IsSelected` state, which is +updated by regenerating the entire `AvailableColorPresets` list after a successful color temperature change. +This ensures the checkmark displays correctly for the newly selected preset. + +**Flyout Display Options:** + +The Flyout UI visibility is controlled by a combination of global settings and per-monitor settings: + +**Global Settings (in `PowerDisplayProperties`):** + +| Setting | Default | Description | +|---------|---------|-------------| +| `ShowProfileSwitcher` | `true` | Show profile switcher (also requires profiles to exist) | +| `ShowIdentifyMonitorsButton` | `true` | Show "Identify Monitors" button | + +**Per-Monitor Settings (in `MonitorInfo`):** + +| Setting | Default | Description | +|---------|---------|-------------| +| `EnableContrast` | `true` (if supported) | Show contrast slider for this monitor | +| `EnableVolume` | `true` (if supported) | Show volume slider for this monitor | +| `EnableInputSource` | `true` (if supported) | Show input source selector for this monitor | +| `EnableRotation` | `true` | Show rotation control for this monitor | +| `EnableColorTemperature` | `true` (if supported) | Show color temperature switcher for this monitor | +| `IsHidden` | `false` | Hide this monitor from the flyout entirely | + +Users can configure per-monitor visibility in Settings UI under the "Monitors" section. Each monitor +shows checkboxes for the features it supports, allowing fine-grained control over the flyout UI. + +**Color Temperature Warning Dialog:** + +When enabling `EnableColorTemperature` for a monitor in Settings UI, a warning dialog is displayed to inform +users about potential risks. Color temperature changes can cause unpredictable results on some monitors, +including incorrect colors, display malfunction, or settings that cannot be reverted. The dialog requires +explicit confirmation before enabling the feature. + +Implementation notes: +- The warning dialog only appears when the user explicitly checks the checkbox (not during initial page load) +- A `_isPageLoaded` flag prevents the dialog from appearing during data binding +- If the user cancels the dialog, the checkbox is reverted to unchecked state + +--- + +### Sequence: Creating and Saving a Profile + +```mermaid +sequenceDiagram + participant User + participant SettingsPage as PowerDisplayPage + participant ViewModel as PowerDisplayViewModel + participant ProfileDialog as ProfileEditorDialog + participant ProfileService + participant FileSystem as profiles.json + + User->>SettingsPage: Clicks "Add Profile" button + SettingsPage->>ViewModel: ShowProfileEditor() + + ViewModel->>ProfileDialog: Show(monitors, existingProfiles) + ProfileDialog->>ProfileDialog: Display monitor selection UI + + User->>ProfileDialog: Enters profile name + User->>ProfileDialog: Selects monitors to include + User->>ProfileDialog: Configures settings per monitor
(brightness, contrast, etc.) + User->>ProfileDialog: Clicks "Save" + + ProfileDialog->>ProfileDialog: Validate inputs + Note over ProfileDialog: Check name unique,
at least one monitor selected + + ProfileDialog-->>ViewModel: ResultProfile (PowerDisplayProfile) + + ViewModel->>ProfileService: AddOrUpdateProfile(profile) + + ProfileService->>ProfileService: lock(_lock) + ProfileService->>FileSystem: Read profiles.json + FileSystem-->>ProfileService: Existing profiles + ProfileService->>ProfileService: Add/update profile in collection + ProfileService->>ProfileService: Set LastUpdated = DateTime.Now + ProfileService->>FileSystem: Write profiles.json + FileSystem-->>ProfileService: Success + ProfileService-->>ViewModel: true + + ViewModel->>ViewModel: RefreshProfilesList() + ViewModel-->>SettingsPage: PropertyChanged(Profiles) + SettingsPage->>SettingsPage: Update UI with new profile +``` + +--- + +### Sequence: Applying Profile via LightSwitch Theme Change + +```mermaid +sequenceDiagram + participant System as Windows System + participant LightSwitch as LightSwitchStateManager (C++) + participant WinEvent as Windows Events + participant EventWaiter as NativeEventWaiter + participant LSSvc as LightSwitchService + participant MainVM as MainViewModel + participant ProfileService + participant MonitorVM as MonitorViewModel + participant Controller as IMonitorController + participant Monitor as Physical Monitor + + Note over System: Time reaches threshold
or user changes theme + System->>LightSwitch: Theme change detected + + LightSwitch->>LightSwitch: EvaluateAndApplyIfNeeded() + LightSwitch->>LightSwitch: ApplyTheme(isLight) + + LightSwitch->>LightSwitch: NotifyPowerDisplay(isLight) + Note over LightSwitch: Check if profile enabled + + alt isLight == true + LightSwitch->>WinEvent: SetEvent("Local\\PowerToys_LightSwitch_LightTheme") + else isLight == false + LightSwitch->>WinEvent: SetEvent("Local\\PowerToys_LightSwitch_DarkTheme") + end + + Note over EventWaiter: Background thread waiting
on both Light and Dark events + EventWaiter->>WinEvent: WaitAny([lightEvent, darkEvent]) returns index + + Note over EventWaiter: Theme determined from event:
index 0 = Light, index 1 = Dark + EventWaiter->>LSSvc: GetProfileForTheme(isLightMode) + LSSvc->>LSSvc: Read LightSwitch/settings.json + LSSvc-->>EventWaiter: profileName (or null) + + EventWaiter->>MainVM: Dispatch to UI thread with profileName + + MainVM->>ProfileService: LoadProfiles() + ProfileService-->>MainVM: PowerDisplayProfiles + + MainVM->>MainVM: Find profile by name + MainVM->>MainVM: ApplyProfileAsync(profile.MonitorSettings) + + loop For each ProfileMonitorSetting + MainVM->>MainVM: Find MonitorViewModel by InternalName + + alt Brightness specified + MainVM->>MonitorVM: SetBrightnessAsync(value, immediate=true) + MonitorVM->>Controller: SetBrightnessAsync(monitor, value) + Controller->>Monitor: DDC/CI or WMI call + Monitor-->>Controller: Success + end + + alt Contrast specified + MainVM->>MonitorVM: SetContrastAsync(value, immediate=true) + MonitorVM->>Controller: SetContrastAsync(monitor, value) + Controller->>Monitor: SetVCPFeature(0x12, value) + end + + alt Volume specified + MainVM->>MonitorVM: SetVolumeAsync(value, immediate=true) + MonitorVM->>Controller: SetVolumeAsync(monitor, value) + Controller->>Monitor: SetVCPFeature(0x62, value) + end + + alt ColorTemperature specified + MainVM->>MonitorVM: SetColorTemperatureAsync(vcpValue) + MonitorVM->>Controller: SetColorTemperatureAsync(monitor, vcpValue) + Controller->>Monitor: SetVCPFeature(0x14, vcpValue) + end + + alt Orientation specified + MainVM->>MonitorVM: SetOrientationAsync(orientation) + MonitorVM->>Controller: SetRotationAsync(monitor, orientation) + Controller->>Monitor: ChangeDisplaySettingsEx + end + end + + Note over MainVM: await Task.WhenAll(updateTasks) + MainVM->>MainVM: Log profile application complete +``` + +--- + +### Sequence: UI Slider Adjustment (Brightness) + +```mermaid +sequenceDiagram + participant User + participant Slider as Brightness Slider + participant MonitorVM as MonitorViewModel + participant Debouncer as SimpleDebouncer + participant MonitorManager + participant Controller as DdcCiController + participant StateManager as MonitorStateManager + participant Monitor as Physical Monitor + + User->>Slider: Drags slider (continuous) + + loop During drag (multiple events) + Slider->>MonitorVM: CurrentBrightness = value + MonitorVM->>MonitorVM: SetBrightnessAsync(value, immediate=false) + MonitorVM->>Debouncer: Debounce(300ms) + Note over Debouncer: Resets timer on each call + end + + User->>Slider: Releases slider + + Note over Debouncer: 300ms elapsed, no new input + Debouncer->>MonitorVM: Execute debounced action + + MonitorVM->>MonitorVM: ApplyBrightnessToHardwareAsync() + MonitorVM->>MonitorManager: SetBrightnessAsync(monitor, finalValue) + + MonitorManager->>Controller: SetBrightnessAsync(monitor, value) + + Controller->>Controller: SetVcpFeatureAsync(VcpCodeBrightness) + Controller->>Monitor: SetVCPFeature(0x10, value) + Monitor-->>Controller: OK + + Controller-->>MonitorManager: MonitorOperationResult + MonitorManager-->>MonitorVM: Success/Failure + + MonitorVM->>StateManager: UpdateMonitorParameter("Brightness", value) + + Note over StateManager: Debounced save (2 seconds) + StateManager->>StateManager: Schedule file write + + Note over StateManager: After 2s idle + StateManager->>StateManager: SaveToFile(monitor_state.json) +``` + +--- + +### Sequence: Module Enable/Disable Lifecycle + +```mermaid +sequenceDiagram + participant Runner as PowerToys Runner + participant ModuleInterface as PowerDisplayModule (C++) + participant PowerDisplayApp as PowerDisplay.exe + participant MonitorManager + participant StateManager as MonitorStateManager + participant EventHandles as Windows Events + + Note over Runner: User enables PowerDisplay + Runner->>ModuleInterface: enable() + + ModuleInterface->>ModuleInterface: m_enabled = true + ModuleInterface->>ModuleInterface: Trace::EnablePowerDisplay(true) + + ModuleInterface->>ModuleInterface: is_process_running() + alt Process not running + ModuleInterface->>PowerDisplayApp: ShellExecuteExW("PowerToys.PowerDisplay.exe", pid) + PowerDisplayApp->>PowerDisplayApp: Initialize WinUI 3 App + PowerDisplayApp->>PowerDisplayApp: RegisterSingletonInstance() + PowerDisplayApp->>MonitorManager: DiscoverMonitorsAsync() + + alt RestoreSettingsOnStartup enabled + PowerDisplayApp->>StateManager: GetMonitorParameters(monitorId) + StateManager-->>PowerDisplayApp: Saved brightness, contrast, etc. + PowerDisplayApp->>MonitorManager: SetBrightnessAsync(savedValue) + PowerDisplayApp->>MonitorManager: SetContrastAsync(savedValue) + Note over PowerDisplayApp: Restore all saved settings to hardware + end + + PowerDisplayApp->>PowerDisplayApp: Start event listeners + PowerDisplayApp->>EventHandles: SetEvent("Ready") + end + + ModuleInterface->>ModuleInterface: m_hProcess = sei.hProcess + + Note over Runner: User presses hotkey + Runner->>ModuleInterface: on_hotkey() + ModuleInterface->>EventHandles: SetEvent(ToggleEvent) + EventHandles->>PowerDisplayApp: Toggle visibility + + Note over Runner: User disables PowerDisplay + Runner->>ModuleInterface: disable() + + ModuleInterface->>EventHandles: ResetEvent(InvokeEvent) + ModuleInterface->>EventHandles: SetEvent(TerminateEvent) + + PowerDisplayApp->>PowerDisplayApp: Receive terminate signal + PowerDisplayApp->>MonitorManager: Dispose() + PowerDisplayApp->>PowerDisplayApp: Application.Exit() + + ModuleInterface->>ModuleInterface: CloseHandle(m_hProcess) + ModuleInterface->>ModuleInterface: m_enabled = false + ModuleInterface->>ModuleInterface: Trace::EnablePowerDisplay(false) +``` + +--- + +## Future Considerations + +### Already Implemented + +- **Monitor Hot-Plug**: `DisplayChangeWatcher` uses WinRT DeviceWatcher + DisplayMonitor API with 1-second debouncing +- **Display Rotation**: `DisplayRotationService` uses Windows ChangeDisplaySettingsEx API +- **LightSwitch Integration**: Automatic profile application on theme changes via `LightSwitchService` +- **Monitor Identification**: Overlay windows showing monitor numbers via `IdentifyWindow` +- **Mirror Mode Support**: Correct orientation sync for multiple monitors sharing the same GDI device name + +### Potential Future Enhancements + +1. **Advanced Color Management**: Integration with Windows Color Management APIs (HDR, ICC profiles) +2. **PIP/PBP Control**: Picture-in-Picture and Picture-by-Picture configuration (VcpCapabilities already parses window capabilities) +3. **Power State Control**: Monitor power on/off via VCP code 0xD6 + +--- + +## References + +- [VESA DDC/CI Standard](https://vesa.org/vesa-standards/) +- [MCCS (Monitor Control Command Set) Specification](https://vesa.org/vesa-standards/) +- [Microsoft High-Level Monitor Configuration API](https://learn.microsoft.com/en-us/windows/win32/monitor/high-level-monitor-configuration-api) +- [WMI Reference](https://learn.microsoft.com/en-us/windows/win32/wmisdk/wmi-reference) +- [WmiMonitorBrightness Class](https://learn.microsoft.com/en-us/windows/win32/wmicoreprov/wmimonitorbrightness) +- [PowerToys Architecture Documentation](../../core/architecture.md) diff --git a/doc/devdocs/modules/powerdisplay/mccsParserDesign.md b/doc/devdocs/modules/powerdisplay/mccsParserDesign.md new file mode 100644 index 0000000000..128407308c --- /dev/null +++ b/doc/devdocs/modules/powerdisplay/mccsParserDesign.md @@ -0,0 +1,223 @@ +# MCCS Capabilities String Parser - Recursive Descent Design + +## Overview + +This document describes the recursive descent parser implementation for DDC/CI MCCS (Monitor Control Command Set) capabilities strings. + +### Attention! +This document and the code implement are generated by Copilot. + +## Grammar Definition (BNF) + +```bnf +capabilities ::= ['('] segment* [')'] +segment ::= identifier '(' segment_content ')' +segment_content ::= text | vcp_entries | hex_list +vcp_entries ::= vcp_entry* +vcp_entry ::= hex_byte [ '(' hex_list ')' ] +hex_list ::= hex_byte* +hex_byte ::= [0-9A-Fa-f]{2} +identifier ::= [a-z_A-Z]+ +text ::= [^()]+ +``` + +## Example Input + +``` +(prot(monitor)type(lcd)model(PD3220U)cmds(01 02 03 07)vcp(10 12 14(04 05 06) 16 60(11 12 0F) DC DF)mccs_ver(2.2)vcpname(F0(Custom Setting))) +``` + +## Parser Architecture + +### Component Hierarchy + +``` +MccsCapabilitiesParser (main parser) +├── ParseCapabilities() → MccsParseResult +├── ParseSegment() → ParsedSegment? +├── ParseBalancedContent() → string +├── ParseIdentifier() → ReadOnlySpan +├── ApplySegment() → void +│ ├── ParseHexList() → List +│ ├── ParseVcpEntries() → Dictionary +│ └── ParseVcpNames() → void +│ +├── VcpEntryParser (sub-parser for vcp() content) +│ └── TryParseEntry() → VcpEntry +│ +├── VcpNameParser (sub-parser for vcpname() content) +│ └── TryParseEntry() → (byte code, string name) +│ +└── WindowParser (sub-parser for windowN() content) + ├── Parse() → WindowCapability + └── ParseSubSegment() → (name, content)? +``` + +### Design Principles + +1. **ref struct for Zero Allocation** + - Main parser uses `ref struct` to avoid heap allocation + - Works with `ReadOnlySpan` for efficient string slicing + - No intermediate string allocations during parsing + +2. **Recursive Descent Pattern** + - Each grammar rule has a corresponding parse method + - Methods call each other recursively for nested structures + - Single-character lookahead via `Peek()` + +3. **Error Recovery** + - Errors are accumulated, not thrown + - Parser attempts to continue after errors + - Returns partial results when possible + +4. **Sub-parsers for Specialized Content** + - `VcpEntryParser` for VCP code entries + - `VcpNameParser` for custom VCP names + - Each sub-parser handles its own grammar subset + +## Parse Methods Detail + +### ParseCapabilities() +Entry point. Handles optional outer parentheses and iterates through segments. + +```csharp +private MccsParseResult ParseCapabilities() +{ + // Handle optional outer parens + // while (!IsAtEnd()) { ParseSegment() } + // Return result with accumulated errors +} +``` + +### ParseSegment() +Parses a single `identifier(content)` segment. + +```csharp +private ParsedSegment? ParseSegment() +{ + // 1. ParseIdentifier() + // 2. Expect '(' + // 3. ParseBalancedContent() + // 4. Expect ')' +} +``` + +### ParseBalancedContent() +Extracts content between balanced parentheses, handling nested parens. + +```csharp +private string ParseBalancedContent() +{ + int depth = 1; + while (depth > 0) { + if (char == '(') depth++; + if (char == ')') depth--; + } +} +``` + +### ParseVcpEntries() +Delegates to `VcpEntryParser` for the specialized VCP entry grammar. + +```csharp +vcp_entry ::= hex_byte [ '(' hex_list ')' ] + +Examples: +- "10" → code=0x10, values=[] +- "14(04 05 06)" → code=0x14, values=[4, 5, 6] +- "60(11 12 0F)" → code=0x60, values=[0x11, 0x12, 0x0F] +``` + +## Comparison with Other Approaches + +| Approach | Pros | Cons | +|----------|------|------| +| **Recursive Descent** (this) | Clear structure, handles nesting, extensible | More code | +| **Regex** (DDCSharp) | Concise | Hard to debug, limited nesting | +| **Mixed** (original) | Pragmatic | Inconsistent, hard to maintain | + +## Performance Characteristics + +- **Time Complexity**: O(n) where n = input length +- **Space Complexity**: O(1) for parsing + O(m) for output where m = number of VCP codes +- **Allocations**: Minimal - only for output structures + +## Supported Segments + +| Segment | Description | Parser | +|---------|-------------|--------| +| `prot(...)` | Protocol type | Direct assignment | +| `type(...)` | Display type (lcd/crt) | Direct assignment | +| `model(...)` | Model name | Direct assignment | +| `cmds(...)` | Supported commands | ParseHexList | +| `vcp(...)` | VCP code entries | VcpEntryParser | +| `mccs_ver(...)` | MCCS version | Direct assignment | +| `vcpname(...)` | Custom VCP names | VcpNameParser | +| `windowN(...)` | PIP/PBP window capabilities | WindowParser | + +### Window Segment Format + +The `windowN` segment (where N is 1, 2, 3, etc.) describes PIP/PBP window capabilities: + +``` +window1(type(PIP) area(25 25 1895 1175) max(640 480) min(10 10) window(10)) +``` + +| Sub-field | Format | Description | +|-----------|--------|-------------| +| `type` | `type(PIP)` or `type(PBP)` | Window type (Picture-in-Picture or Picture-by-Picture) | +| `area` | `area(x1 y1 x2 y2)` | Window area coordinates in pixels | +| `max` | `max(width height)` | Maximum window dimensions | +| `min` | `min(width height)` | Minimum window dimensions | +| `window` | `window(id)` | Window identifier | + +All sub-fields are optional; missing fields default to zero values. + +## Error Handling + +```csharp +public readonly struct ParseError +{ + public int Position { get; } // Character position + public string Message { get; } // Human-readable error +} + +public sealed class MccsParseResult +{ + public VcpCapabilities Capabilities { get; } + public IReadOnlyList Errors { get; } + public bool HasErrors => Errors.Count > 0; + public bool IsValid => !HasErrors && Capabilities.SupportedVcpCodes.Count > 0; +} +``` + +## Usage Example + +```csharp +// Parse capabilities string +var result = MccsCapabilitiesParser.Parse(capabilitiesString); + +if (result.IsValid) +{ + var caps = result.Capabilities; + Console.WriteLine($"Model: {caps.Model}"); + Console.WriteLine($"MCCS Version: {caps.MccsVersion}"); + Console.WriteLine($"VCP Codes: {caps.SupportedVcpCodes.Count}"); +} + +if (result.HasErrors) +{ + foreach (var error in result.Errors) + { + Console.WriteLine($"Parse error at {error.Position}: {error.Message}"); + } +} +``` + +## Edge Cases Handled + +1. **Missing outer parentheses** (Apple Cinema Display) +2. **No spaces between hex bytes** (`010203` vs `01 02 03`) +3. **Nested parentheses** in VCP values +4. **Unknown segments** (logged but not fatal) +5. **Malformed input** (partial results returned) diff --git a/installer/PowerToysSetupCustomActionsVNext/CustomAction.cpp b/installer/PowerToysSetupCustomActionsVNext/CustomAction.cpp index 40c3d5b0e8..43919ecaf1 100644 --- a/installer/PowerToysSetupCustomActionsVNext/CustomAction.cpp +++ b/installer/PowerToysSetupCustomActionsVNext/CustomAction.cpp @@ -1549,7 +1549,7 @@ UINT __stdcall TerminateProcessesCA(MSIHANDLE hInstall) } processes.resize(bytes / sizeof(processes[0])); - std::array processesToTerminate = { + std::array processesToTerminate = { L"PowerToys.PowerLauncher.exe", L"PowerToys.Settings.exe", L"PowerToys.AdvancedPaste.exe", @@ -1565,6 +1565,7 @@ UINT __stdcall TerminateProcessesCA(MSIHANDLE hInstall) L"PowerToys.PowerRename.exe", L"PowerToys.ImageResizer.exe", L"PowerToys.LightSwitchService.exe", + L"PowerToys.PowerDisplay.exe", L"PowerToys.GcodeThumbnailProvider.exe", L"PowerToys.BgcodeThumbnailProvider.exe", L"PowerToys.PdfThumbnailProvider.exe", diff --git a/installer/PowerToysSetupVNext/PowerDisplay.wxs b/installer/PowerToysSetupVNext/PowerDisplay.wxs new file mode 100644 index 0000000000..5cfe23661c --- /dev/null +++ b/installer/PowerToysSetupVNext/PowerDisplay.wxs @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/installer/PowerToysSetupVNext/PowerToysInstallerVNext.wixproj b/installer/PowerToysSetupVNext/PowerToysInstallerVNext.wixproj index a7a9744e87..4000503edf 100644 --- a/installer/PowerToysSetupVNext/PowerToysInstallerVNext.wixproj +++ b/installer/PowerToysSetupVNext/PowerToysInstallerVNext.wixproj @@ -47,6 +47,7 @@ call powershell.exe -NonInteractive -executionpolicy Unrestricted -File $(MSBuil call move /Y ..\..\..\NewPlus.wxs.bk ..\..\..\NewPlus.wxs call move /Y ..\..\..\Peek.wxs.bk ..\..\..\Peek.wxs call move /Y ..\..\..\PowerRename.wxs.bk ..\..\..\PowerRename.wxs + call move /Y ..\..\..\PowerDisplay.wxs.bk ..\..\..\PowerDisplay.wxs call move /Y ..\..\..\Product.wxs.bk ..\..\..\Product.wxs call move /Y ..\..\..\RegistryPreview.wxs.bk ..\..\..\RegistryPreview.wxs call move /Y ..\..\..\Resources.wxs.bk ..\..\..\Resources.wxs @@ -123,6 +124,7 @@ call powershell.exe -NonInteractive -executionpolicy Unrestricted -File $(MSBuil + diff --git a/installer/PowerToysSetupVNext/Product.wxs b/installer/PowerToysSetupVNext/Product.wxs index a5615870f9..5256af42fd 100644 --- a/installer/PowerToysSetupVNext/Product.wxs +++ b/installer/PowerToysSetupVNext/Product.wxs @@ -53,6 +53,7 @@ + diff --git a/installer/PowerToysSetupVNext/generateAllFileComponents.ps1 b/installer/PowerToysSetupVNext/generateAllFileComponents.ps1 index 6724d95170..2ada2b17d1 100644 --- a/installer/PowerToysSetupVNext/generateAllFileComponents.ps1 +++ b/installer/PowerToysSetupVNext/generateAllFileComponents.ps1 @@ -176,6 +176,10 @@ Generate-FileComponents -fileListName "ImageResizerAssetsFiles" -wxsFilePath $PS Generate-FileList -fileDepsJson "" -fileListName LightSwitchFiles -wxsFilePath $PSScriptRoot\LightSwitch.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\LightSwitchService" Generate-FileComponents -fileListName "LightSwitchFiles" -wxsFilePath $PSScriptRoot\LightSwitch.wxs +#PowerDisplay +Generate-FileList -fileDepsJson "" -fileListName PowerDisplayAssetsFiles -wxsFilePath $PSScriptRoot\PowerDisplay.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\WinUI3Apps\Assets\PowerDisplay" +Generate-FileComponents -fileListName "PowerDisplayAssetsFiles" -wxsFilePath $PSScriptRoot\PowerDisplay.wxs + #New+ Generate-FileList -fileDepsJson "" -fileListName NewPlusAssetsFiles -wxsFilePath $PSScriptRoot\NewPlus.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\WinUI3Apps\Assets\NewPlus" Generate-FileComponents -fileListName "NewPlusAssetsFiles" -wxsFilePath $PSScriptRoot\NewPlus.wxs diff --git a/src/common/Common.UI/SettingsDeepLink.cs b/src/common/Common.UI/SettingsDeepLink.cs index 5233c0d668..fedf5480e3 100644 --- a/src/common/Common.UI/SettingsDeepLink.cs +++ b/src/common/Common.UI/SettingsDeepLink.cs @@ -45,6 +45,7 @@ namespace Common.UI NewPlus, CmdPal, ZoomIt, + PowerDisplay, } private static string SettingsWindowNameToString(SettingsWindow value) @@ -115,6 +116,8 @@ namespace Common.UI return "CmdPal"; case SettingsWindow.ZoomIt: return "ZoomIt"; + case SettingsWindow.PowerDisplay: + return "PowerDisplay"; default: { return string.Empty; diff --git a/src/common/GPOWrapper/GPOWrapper.cpp b/src/common/GPOWrapper/GPOWrapper.cpp index 2b256cd926..1132df9599 100644 --- a/src/common/GPOWrapper/GPOWrapper.cpp +++ b/src/common/GPOWrapper/GPOWrapper.cpp @@ -32,6 +32,10 @@ namespace winrt::PowerToys::GPOWrapper::implementation { return static_cast(powertoys_gpo::getConfiguredLightSwitchEnabledValue()); } + GpoRuleConfigured GPOWrapper::GetConfiguredPowerDisplayEnabledValue() + { + return static_cast(powertoys_gpo::getConfiguredPowerDisplayEnabledValue()); + } GpoRuleConfigured GPOWrapper::GetConfiguredFancyZonesEnabledValue() { return static_cast(powertoys_gpo::getConfiguredFancyZonesEnabledValue()); diff --git a/src/common/GPOWrapper/GPOWrapper.h b/src/common/GPOWrapper/GPOWrapper.h index e57cccccd9..aceb3bf756 100644 --- a/src/common/GPOWrapper/GPOWrapper.h +++ b/src/common/GPOWrapper/GPOWrapper.h @@ -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(); diff --git a/src/common/GPOWrapper/GPOWrapper.idl b/src/common/GPOWrapper/GPOWrapper.idl index 06d035aa35..58c35cd977 100644 --- a/src/common/GPOWrapper/GPOWrapper.idl +++ b/src/common/GPOWrapper/GPOWrapper.idl @@ -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(); diff --git a/src/common/ManagedCommon/ModuleType.cs b/src/common/ManagedCommon/ModuleType.cs index 8461b4a6d8..548276f725 100644 --- a/src/common/ManagedCommon/ModuleType.cs +++ b/src/common/ManagedCommon/ModuleType.cs @@ -30,6 +30,7 @@ namespace ManagedCommon PowerRename, PowerLauncher, PowerAccent, + PowerDisplay, RegistryPreview, MeasureTool, ShortcutGuide, diff --git a/src/common/interop/Constants.cpp b/src/common/interop/Constants.cpp index fef43de566..67b4da51f2 100644 --- a/src/common/interop/Constants.cpp +++ b/src/common/interop/Constants.cpp @@ -251,4 +251,40 @@ namespace winrt::PowerToys::Interop::implementation { return CommonSharedConstants::CMDPAL_SHOW_EVENT; } + hstring Constants::TogglePowerDisplayEvent() + { + return CommonSharedConstants::TOGGLE_POWER_DISPLAY_EVENT; + } + hstring Constants::TerminatePowerDisplayEvent() + { + return CommonSharedConstants::TERMINATE_POWER_DISPLAY_EVENT; + } + hstring Constants::RefreshPowerDisplayMonitorsEvent() + { + return CommonSharedConstants::REFRESH_POWER_DISPLAY_MONITORS_EVENT; + } + hstring Constants::SettingsUpdatedPowerDisplayEvent() + { + return CommonSharedConstants::SETTINGS_UPDATED_POWER_DISPLAY_EVENT; + } + hstring Constants::PowerDisplaySendSettingsTelemetryEvent() + { + return CommonSharedConstants::POWER_DISPLAY_SEND_SETTINGS_TELEMETRY_EVENT; + } + hstring Constants::HotkeyUpdatedPowerDisplayEvent() + { + return CommonSharedConstants::HOTKEY_UPDATED_POWER_DISPLAY_EVENT; + } + hstring Constants::PowerDisplayToggleMessage() + { + return CommonSharedConstants::POWER_DISPLAY_TOGGLE_MESSAGE; + } + hstring Constants::PowerDisplayApplyProfileMessage() + { + return CommonSharedConstants::POWER_DISPLAY_APPLY_PROFILE_MESSAGE; + } + hstring Constants::PowerDisplayTerminateAppMessage() + { + return CommonSharedConstants::POWER_DISPLAY_TERMINATE_APP_MESSAGE; + } } diff --git a/src/common/interop/Constants.h b/src/common/interop/Constants.h index cdd883cc41..faa2a97379 100644 --- a/src/common/interop/Constants.h +++ b/src/common/interop/Constants.h @@ -66,6 +66,15 @@ namespace winrt::PowerToys::Interop::implementation static hstring WorkspacesHotkeyEvent(); static hstring PowerToysRunnerTerminateSettingsEvent(); static hstring ShowCmdPalEvent(); + static hstring TogglePowerDisplayEvent(); + static hstring TerminatePowerDisplayEvent(); + static hstring RefreshPowerDisplayMonitorsEvent(); + static hstring SettingsUpdatedPowerDisplayEvent(); + static hstring PowerDisplaySendSettingsTelemetryEvent(); + static hstring HotkeyUpdatedPowerDisplayEvent(); + static hstring PowerDisplayToggleMessage(); + static hstring PowerDisplayApplyProfileMessage(); + static hstring PowerDisplayTerminateAppMessage(); }; } diff --git a/src/common/interop/Constants.idl b/src/common/interop/Constants.idl index abd642b197..042d790699 100644 --- a/src/common/interop/Constants.idl +++ b/src/common/interop/Constants.idl @@ -63,6 +63,15 @@ namespace PowerToys static String WorkspacesHotkeyEvent(); static String PowerToysRunnerTerminateSettingsEvent(); static String ShowCmdPalEvent(); + static String TogglePowerDisplayEvent(); + static String TerminatePowerDisplayEvent(); + static String RefreshPowerDisplayMonitorsEvent(); + static String SettingsUpdatedPowerDisplayEvent(); + static String PowerDisplaySendSettingsTelemetryEvent(); + static String HotkeyUpdatedPowerDisplayEvent(); + static String PowerDisplayToggleMessage(); + static String PowerDisplayApplyProfileMessage(); + static String PowerDisplayTerminateAppMessage(); } } } diff --git a/src/common/interop/shared_constants.h b/src/common/interop/shared_constants.h index 3dad776cf6..079f53c85c 100644 --- a/src/common/interop/shared_constants.h +++ b/src/common/interop/shared_constants.h @@ -153,6 +153,23 @@ namespace CommonSharedConstants const wchar_t ZOOMIT_SNIP_EVENT[] = L"Local\\PowerToysZoomIt-SnipEvent-2fd9c211-436d-4f17-a902-2528aaae3e30"; const wchar_t ZOOMIT_RECORD_EVENT[] = L"Local\\PowerToysZoomIt-RecordEvent-74539344-eaad-4711-8e83-23946e424512"; + // Path to the events used by PowerDisplay + const wchar_t TOGGLE_POWER_DISPLAY_EVENT[] = L"Local\\PowerToysPowerDisplay-ToggleEvent-5f1a9c3e-7d2b-4e8f-9a6c-3b5d7e9f1a2c"; + const wchar_t TERMINATE_POWER_DISPLAY_EVENT[] = L"Local\\PowerToysPowerDisplay-TerminateEvent-7b9c2e1f-8a5d-4c3e-9f6b-2a1d8c5e3b7a"; + const wchar_t REFRESH_POWER_DISPLAY_MONITORS_EVENT[] = L"Local\\PowerToysPowerDisplay-RefreshMonitorsEvent-a3f5c8e7-9d1b-4e2f-8c6a-3b5d7e9f1a2c"; + const wchar_t SETTINGS_UPDATED_POWER_DISPLAY_EVENT[] = L"Local\\PowerToysPowerDisplay-SettingsUpdatedEvent-2e4d6f8a-1c3b-5e7f-9a1d-4c6e8f0b2d3e"; + const wchar_t POWER_DISPLAY_SEND_SETTINGS_TELEMETRY_EVENT[] = L"Local\\PowerToysPowerDisplay-SettingsTelemetryEvent-8c4f2a1d-5e3b-7f9c-1a6d-3b8e5f2c9a7d"; + const wchar_t HOTKEY_UPDATED_POWER_DISPLAY_EVENT[] = L"Local\\PowerToysPowerDisplay-HotkeyUpdatedEvent-9d5f3a2b-7e1c-4b8a-6f3d-2a9e5c7b1d4f"; + + // IPC Messages used in PowerDisplay (Named Pipe communication) + const wchar_t POWER_DISPLAY_TOGGLE_MESSAGE[] = L"Toggle"; + const wchar_t POWER_DISPLAY_APPLY_PROFILE_MESSAGE[] = L"ApplyProfile"; + const wchar_t POWER_DISPLAY_TERMINATE_APP_MESSAGE[] = L"TerminateApp"; + + // Path to the events used by LightSwitch to notify PowerDisplay of theme changes + const wchar_t LIGHT_SWITCH_LIGHT_THEME_EVENT[] = L"Local\\PowerToysLightSwitch-LightThemeEvent-50077121-2ffc-4841-9c86-ab1bd3f9baca"; + const wchar_t LIGHT_SWITCH_DARK_THEME_EVENT[] = L"Local\\PowerToysLightSwitch-DarkThemeEvent-b3a835c0-eaa2-49b0-b8eb-f793e3df3368"; + // used from quick access window const wchar_t CMDPAL_SHOW_EVENT[] = L"Local\\PowerToysCmdPal-ShowEvent-62336fcd-8611-4023-9b30-091a6af4cc5a"; const wchar_t CMDPAL_EXIT_EVENT[] = L"Local\\PowerToysCmdPal-ExitEvent-eb73f6be-3f22-4b36-aee3-62924ba40bfd"; diff --git a/src/common/logger/logger_settings.h b/src/common/logger/logger_settings.h index 881633e05e..6f0592ea53 100644 --- a/src/common/logger/logger_settings.h +++ b/src/common/logger/logger_settings.h @@ -83,6 +83,7 @@ struct LogSettings inline const static std::wstring workspacesSnapshotToolLogPath = L"workspaces-snapshot-tool-log.log"; inline const static std::string zoomItLoggerName = "zoom-it"; inline const static std::string lightSwitchLoggerName = "light-switch"; + inline const static std::string powerDisplayLoggerName = "powerdisplay"; inline const static int retention = 30; std::wstring logLevel; LogSettings(); diff --git a/src/common/utils/gpo.h b/src/common/utils/gpo.h index ab71d09d0b..0b2611b076 100644 --- a/src/common/utils/gpo.h +++ b/src/common/utils/gpo.h @@ -32,6 +32,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"; @@ -310,6 +311,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); diff --git a/src/gpo/assets/PowerToys.admx b/src/gpo/assets/PowerToys.admx index eb8eb92b93..ddef3d95eb 100644 --- a/src/gpo/assets/PowerToys.admx +++ b/src/gpo/assets/PowerToys.admx @@ -149,6 +149,16 @@ + + + + + + + + + + diff --git a/src/gpo/assets/en-US/PowerToys.adml b/src/gpo/assets/en-US/PowerToys.adml index fe0611022a..ccd38d9934 100644 --- a/src/gpo/assets/en-US/PowerToys.adml +++ b/src/gpo/assets/en-US/PowerToys.adml @@ -248,6 +248,7 @@ If you don't configure this policy, the user will be able to control the setting CmdPal: Configure enabled state Crop And Lock: Configure enabled state Light Switch: Configure enabled state + PowerDisplay: Configure enabled state Environment Variables: Configure enabled state FancyZones: Configure enabled state File Locksmith: Configure enabled state diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchSettings.cpp b/src/modules/LightSwitch/LightSwitchService/LightSwitchSettings.cpp index 488142b95b..15e9f7c915 100644 --- a/src/modules/LightSwitch/LightSwitchService/LightSwitchSettings.cpp +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchSettings.cpp @@ -248,6 +248,46 @@ void LightSwitchSettings::LoadSettings() } } + // EnableDarkModeProfile + if (const auto jsonVal = values.get_bool_value(L"enableDarkModeProfile")) + { + auto val = *jsonVal; + if (m_settings.enableDarkModeProfile != val) + { + m_settings.enableDarkModeProfile = val; + } + } + + // EnableLightModeProfile + if (const auto jsonVal = values.get_bool_value(L"enableLightModeProfile")) + { + auto val = *jsonVal; + if (m_settings.enableLightModeProfile != val) + { + m_settings.enableLightModeProfile = val; + } + } + + // DarkModeProfile + if (const auto jsonVal = values.get_string_value(L"darkModeProfile")) + { + auto val = *jsonVal; + if (m_settings.darkModeProfile != val) + { + m_settings.darkModeProfile = val; + } + } + + // LightModeProfile + if (const auto jsonVal = values.get_string_value(L"lightModeProfile")) + { + auto val = *jsonVal; + if (m_settings.lightModeProfile != val) + { + m_settings.lightModeProfile = val; + } + } + // For ChangeSystem/ChangeApps changes, log telemetry if (themeTargetChanged) { diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchSettings.h b/src/modules/LightSwitch/LightSwitchService/LightSwitchSettings.h index 1d1c7953fe..4fd9777c5e 100644 --- a/src/modules/LightSwitch/LightSwitchService/LightSwitchSettings.h +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchSettings.h @@ -67,6 +67,11 @@ struct LightSwitchConfig bool changeSystem = false; bool changeApps = false; + + bool enableDarkModeProfile = false; + bool enableLightModeProfile = false; + std::wstring darkModeProfile = L""; + std::wstring lightModeProfile = L""; }; class LightSwitchSettings diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.cpp b/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.cpp index cc4f959881..28bcca6512 100644 --- a/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.cpp +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.cpp @@ -4,6 +4,7 @@ #include #include "ThemeScheduler.h" #include +#include void ApplyTheme(bool shouldBeLight); @@ -37,7 +38,7 @@ void LightSwitchStateManager::OnTick() } } -// Called when manual override is triggered +// Called when manual override is triggered (via hotkey) void LightSwitchStateManager::OnManualOverride() { std::lock_guard lock(_stateMutex); @@ -45,15 +46,19 @@ void LightSwitchStateManager::OnManualOverride() _state.isManualOverride = !_state.isManualOverride; // When entering manual override, sync internal theme state to match the current system + // The hotkey handler in ModuleInterface has already toggled the theme, so we read the new state if (_state.isManualOverride) { _state.isSystemLightActive = GetCurrentSystemTheme(); - _state.isAppsLightActive = GetCurrentAppsTheme(); Logger::debug(L"[LightSwitchStateManager] Synced internal theme state to current system theme ({}) and apps theme ({}).", (_state.isSystemLightActive ? L"light" : L"dark"), (_state.isAppsLightActive ? L"light" : L"dark")); + + // Notify PowerDisplay about the theme change triggered by hotkey + // The theme has already been applied by ModuleInterface, we just need to notify PowerDisplay + NotifyPowerDisplay(_state.isSystemLightActive); } EvaluateAndApplyIfNeeded(); @@ -268,7 +273,61 @@ void LightSwitchStateManager::EvaluateAndApplyIfNeeded() _state.isSystemLightActive = GetCurrentSystemTheme(); _state.isAppsLightActive = GetCurrentAppsTheme(); + + // Notify PowerDisplay to apply display profile if configured + NotifyPowerDisplay(shouldBeLight); } _state.lastTickMinutes = now; } + +// Notify PowerDisplay module about theme change to apply display profiles +void LightSwitchStateManager::NotifyPowerDisplay(bool isLight) +{ + const auto& settings = LightSwitchSettings::settings(); + + // Check if any profile is enabled and configured + bool shouldNotify = false; + + if (isLight && settings.enableLightModeProfile && !settings.lightModeProfile.empty()) + { + shouldNotify = true; + } + else if (!isLight && settings.enableDarkModeProfile && !settings.darkModeProfile.empty()) + { + shouldNotify = true; + } + + if (!shouldNotify) + { + return; + } + + try + { + // Signal PowerDisplay with the specific theme event + // Using separate events for light/dark eliminates race conditions where PowerDisplay + // might read the registry before LightSwitch has finished updating it + const wchar_t* eventName = isLight + ? CommonSharedConstants::LIGHT_SWITCH_LIGHT_THEME_EVENT + : CommonSharedConstants::LIGHT_SWITCH_DARK_THEME_EVENT; + + Logger::info(L"[LightSwitchStateManager] Notifying PowerDisplay about theme change (isLight: {})", isLight); + + HANDLE hThemeEvent = CreateEventW(nullptr, FALSE, FALSE, eventName); + if (hThemeEvent) + { + SetEvent(hThemeEvent); + CloseHandle(hThemeEvent); + Logger::info(L"[LightSwitchStateManager] Theme event signaled to PowerDisplay: {}", eventName); + } + else + { + Logger::warn(L"[LightSwitchStateManager] Failed to create theme event (error: {})", GetLastError()); + } + } + catch (...) + { + Logger::error(L"[LightSwitchStateManager] Failed to notify PowerDisplay"); + } +} diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.h b/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.h index 65d6f7ada7..b6c001fc64 100644 --- a/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.h +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.h @@ -48,4 +48,7 @@ private: void EvaluateAndApplyIfNeeded(); bool CoordinatesAreValid(const std::wstring& lat, const std::wstring& lon); + + // Notify PowerDisplay module about theme change to apply display profiles + void NotifyPowerDisplay(bool isLight); }; diff --git a/src/modules/powerdisplay/PowerDisplay.Lib.UnitTests/MccsCapabilitiesParserTests.cs b/src/modules/powerdisplay/PowerDisplay.Lib.UnitTests/MccsCapabilitiesParserTests.cs new file mode 100644 index 0000000000..55f5e574ff --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib.UnitTests/MccsCapabilitiesParserTests.cs @@ -0,0 +1,737 @@ +// 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.Linq; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using PowerDisplay.Common.Utils; + +namespace PowerDisplay.UnitTests; + +/// +/// Unit tests for MccsCapabilitiesParser class. +/// +[TestClass] +public class MccsCapabilitiesParserTests +{ + private const string DellU3011Capabilities = + "(prot(monitor)type(lcd)model(U3011)cmds(01 02 03 07 0C E3 F3)vcp(02 04 05 06 08 10 12 14(01 05 08 0B 0C) 16 18 1A 52 60(01 03 04 0C 0F 11 12) AC AE B2 B6 C6 C8 C9 D6(01 04 05) DC(00 02 03 04 05) DF FD)mccs_ver(2.1)mswhql(1))"; + + // Real capabilities string from Dell P2416D monitor + private const string DellP2416DCapabilities = + "(prot(monitor)type(LCD)model(P2416D)cmds(01 02 03 07 0C E3 F3) vcp(02 04 05 08 10 12 14(05 08 0B 0C) 16 18 1A 52 60(01 11 0F) AA(01 02) AC AE B2 B6 C6 C8 C9 D6(01 04 05) DC(00 02 03 05) DF E0 E1 E2(00 01 02 04 0E 12 14 19) F0(00 08) F1(01 02) F2 FD) mswhql(1)asset_eep(40)mccs_ver(2.1))"; + + // Simple test string + private const string SimpleCapabilities = + "(prot(monitor)type(lcd)model(TestMonitor)vcp(10 12)mccs_ver(2.2))"; + + // Capabilities without outer parentheses (some monitors like Apple Cinema Display) + private const string NoOuterParensCapabilities = + "prot(monitor)type(lcd)model(TestMonitor)vcp(10 12)mccs_ver(2.0)"; + + // Concatenated hex format (no spaces between hex bytes) + private const string ConcatenatedHexCapabilities = + "(prot(monitor)cmds(01020307)vcp(101214)mccs_ver(2.1))"; + + [TestMethod] + public void Parse_NullInput_ReturnsEmptyCapabilities() + { + // Act + var result = MccsCapabilitiesParser.Parse(null); + + // Assert + Assert.IsNotNull(result); + Assert.IsNotNull(result.Capabilities); + Assert.AreEqual(0, result.Capabilities.SupportedVcpCodes.Count); + Assert.IsFalse(result.HasErrors); + } + + [TestMethod] + public void Parse_EmptyString_ReturnsEmptyCapabilities() + { + // Act + var result = MccsCapabilitiesParser.Parse(string.Empty); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(0, result.Capabilities.SupportedVcpCodes.Count); + } + + [TestMethod] + public void Parse_WhitespaceOnly_ReturnsEmptyCapabilities() + { + // Act + var result = MccsCapabilitiesParser.Parse(" \t\n "); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(0, result.Capabilities.SupportedVcpCodes.Count); + } + + [TestMethod] + public void Parse_DellU3011_ParsesProtocol() + { + // Act + var result = MccsCapabilitiesParser.Parse(DellU3011Capabilities); + + // Assert + Assert.AreEqual("monitor", result.Capabilities.Protocol); + } + + [TestMethod] + public void Parse_DellU3011_ParsesType() + { + // Act + var result = MccsCapabilitiesParser.Parse(DellU3011Capabilities); + + // Assert + Assert.AreEqual("lcd", result.Capabilities.Type); + } + + [TestMethod] + public void Parse_DellU3011_ParsesModel() + { + // Act + var result = MccsCapabilitiesParser.Parse(DellU3011Capabilities); + + // Assert + Assert.AreEqual("U3011", result.Capabilities.Model); + } + + [TestMethod] + public void Parse_DellU3011_ParsesMccsVersion() + { + // Act + var result = MccsCapabilitiesParser.Parse(DellU3011Capabilities); + + // Assert + Assert.AreEqual("2.1", result.Capabilities.MccsVersion); + } + + [TestMethod] + public void Parse_DellU3011_ParsesCommands() + { + // Act + var result = MccsCapabilitiesParser.Parse(DellU3011Capabilities); + + // Assert + var cmds = result.Capabilities.SupportedCommands; + Assert.IsNotNull(cmds); + Assert.AreEqual(7, cmds.Count); + CollectionAssert.Contains(cmds, (byte)0x01); + CollectionAssert.Contains(cmds, (byte)0x02); + CollectionAssert.Contains(cmds, (byte)0x03); + CollectionAssert.Contains(cmds, (byte)0x07); + CollectionAssert.Contains(cmds, (byte)0x0C); + CollectionAssert.Contains(cmds, (byte)0xE3); + CollectionAssert.Contains(cmds, (byte)0xF3); + } + + [TestMethod] + public void Parse_DellU3011_ParsesBrightnessVcpCode() + { + // Act + var result = MccsCapabilitiesParser.Parse(DellU3011Capabilities); + + // Assert - VCP 0x10 is Brightness + Assert.IsTrue(result.Capabilities.SupportsVcpCode(0x10)); + var brightnessInfo = result.Capabilities.GetVcpCodeInfo(0x10); + Assert.IsNotNull(brightnessInfo); + Assert.AreEqual(0x10, brightnessInfo.Value.Code); + Assert.IsTrue(brightnessInfo.Value.IsContinuous); + } + + [TestMethod] + public void Parse_DellU3011_ParsesContrastVcpCode() + { + // Act + var result = MccsCapabilitiesParser.Parse(DellU3011Capabilities); + + // Assert - VCP 0x12 is Contrast + Assert.IsTrue(result.Capabilities.SupportsVcpCode(0x12)); + } + + [TestMethod] + public void Parse_DellU3011_ParsesInputSourceWithDiscreteValues() + { + // Act + var result = MccsCapabilitiesParser.Parse(DellU3011Capabilities); + + // Assert - VCP 0x60 is Input Source with discrete values + Assert.IsTrue(result.Capabilities.SupportsVcpCode(0x60)); + var inputSourceInfo = result.Capabilities.GetVcpCodeInfo(0x60); + Assert.IsNotNull(inputSourceInfo); + Assert.IsTrue(inputSourceInfo.Value.HasDiscreteValues); + + // Should have values: 01 03 04 0C 0F 11 12 + var values = inputSourceInfo.Value.SupportedValues; + Assert.AreEqual(7, values.Count); + Assert.IsTrue(values.Contains(0x01)); + Assert.IsTrue(values.Contains(0x03)); + Assert.IsTrue(values.Contains(0x04)); + Assert.IsTrue(values.Contains(0x0C)); + Assert.IsTrue(values.Contains(0x0F)); + Assert.IsTrue(values.Contains(0x11)); + Assert.IsTrue(values.Contains(0x12)); + } + + [TestMethod] + public void Parse_DellU3011_ParsesColorPresetWithDiscreteValues() + { + // Act + var result = MccsCapabilitiesParser.Parse(DellU3011Capabilities); + + // Assert - VCP 0x14 is Color Preset + Assert.IsTrue(result.Capabilities.SupportsVcpCode(0x14)); + var colorPresetInfo = result.Capabilities.GetVcpCodeInfo(0x14); + Assert.IsNotNull(colorPresetInfo); + Assert.IsTrue(colorPresetInfo.Value.HasDiscreteValues); + + // Should have values: 01 05 08 0B 0C + var values = colorPresetInfo.Value.SupportedValues; + Assert.AreEqual(5, values.Count); + Assert.IsTrue(values.Contains(0x01)); + Assert.IsTrue(values.Contains(0x05)); + Assert.IsTrue(values.Contains(0x08)); + Assert.IsTrue(values.Contains(0x0B)); + Assert.IsTrue(values.Contains(0x0C)); + } + + [TestMethod] + public void Parse_DellU3011_ParsesPowerModeWithDiscreteValues() + { + // Act + var result = MccsCapabilitiesParser.Parse(DellU3011Capabilities); + + // Assert - VCP 0xD6 is Power Mode + Assert.IsTrue(result.Capabilities.SupportsVcpCode(0xD6)); + var powerModeInfo = result.Capabilities.GetVcpCodeInfo(0xD6); + Assert.IsNotNull(powerModeInfo); + Assert.IsTrue(powerModeInfo.Value.HasDiscreteValues); + + // Should have values: 01 04 05 + var values = powerModeInfo.Value.SupportedValues; + Assert.AreEqual(3, values.Count); + Assert.IsTrue(values.Contains(0x01)); + Assert.IsTrue(values.Contains(0x04)); + Assert.IsTrue(values.Contains(0x05)); + } + + [TestMethod] + public void Parse_DellU3011_TotalVcpCodeCount() + { + // Act + var result = MccsCapabilitiesParser.Parse(DellU3011Capabilities); + + // Assert - VCP codes: 02 04 05 06 08 10 12 14 16 18 1A 52 60 AC AE B2 B6 C6 C8 C9 D6 DC DF FD + Assert.AreEqual(24, result.Capabilities.SupportedVcpCodes.Count); + } + + [TestMethod] + public void Parse_DellP2416D_ParsesModel() + { + // Act + var result = MccsCapabilitiesParser.Parse(DellP2416DCapabilities); + + // Assert + Assert.AreEqual("P2416D", result.Capabilities.Model); + } + + [TestMethod] + public void Parse_DellP2416D_ParsesTypeWithDifferentCase() + { + // Act + var result = MccsCapabilitiesParser.Parse(DellP2416DCapabilities); + + // Assert - Type is "LCD" (uppercase) in this monitor + Assert.AreEqual("LCD", result.Capabilities.Type); + } + + [TestMethod] + public void Parse_DellP2416D_ParsesMccsVersion() + { + // Act + var result = MccsCapabilitiesParser.Parse(DellP2416DCapabilities); + + // Assert + Assert.AreEqual("2.1", result.Capabilities.MccsVersion); + } + + [TestMethod] + public void Parse_DellP2416D_ParsesInputSourceWithThreeValues() + { + // Act + var result = MccsCapabilitiesParser.Parse(DellP2416DCapabilities); + + // Assert - VCP 0x60 Input Source has values: 01 11 0F + Assert.IsTrue(result.Capabilities.SupportsVcpCode(0x60)); + var inputSourceInfo = result.Capabilities.GetVcpCodeInfo(0x60); + Assert.IsNotNull(inputSourceInfo); + + var values = inputSourceInfo.Value.SupportedValues; + Assert.AreEqual(3, values.Count); + Assert.IsTrue(values.Contains(0x01)); + Assert.IsTrue(values.Contains(0x11)); + Assert.IsTrue(values.Contains(0x0F)); + } + + [TestMethod] + public void Parse_DellP2416D_ParsesE2WithManyValues() + { + // Act + var result = MccsCapabilitiesParser.Parse(DellP2416DCapabilities); + + // Assert - VCP 0xE2 has values: 00 01 02 04 0E 12 14 19 + Assert.IsTrue(result.Capabilities.SupportsVcpCode(0xE2)); + var e2Info = result.Capabilities.GetVcpCodeInfo(0xE2); + Assert.IsNotNull(e2Info); + + var values = e2Info.Value.SupportedValues; + Assert.AreEqual(8, values.Count); + } + + [TestMethod] + public void Parse_NoOuterParentheses_StillParses() + { + // Act - Some monitors like Apple Cinema Display omit outer parens + var result = MccsCapabilitiesParser.Parse(NoOuterParensCapabilities); + + // Assert + Assert.AreEqual("monitor", result.Capabilities.Protocol); + Assert.AreEqual("lcd", result.Capabilities.Type); + Assert.AreEqual("TestMonitor", result.Capabilities.Model); + Assert.AreEqual("2.0", result.Capabilities.MccsVersion); + Assert.IsTrue(result.Capabilities.SupportsVcpCode(0x10)); + Assert.IsTrue(result.Capabilities.SupportsVcpCode(0x12)); + } + + [TestMethod] + public void Parse_ConcatenatedHexFormat_ParsesCorrectly() + { + // Act - Some monitors output hex without spaces: cmds(01020307) + var result = MccsCapabilitiesParser.Parse(ConcatenatedHexCapabilities); + + // Assert + var cmds = result.Capabilities.SupportedCommands; + Assert.AreEqual(4, cmds.Count); + CollectionAssert.Contains(cmds, (byte)0x01); + CollectionAssert.Contains(cmds, (byte)0x02); + CollectionAssert.Contains(cmds, (byte)0x03); + CollectionAssert.Contains(cmds, (byte)0x07); + + // VCP codes without spaces + Assert.IsTrue(result.Capabilities.SupportsVcpCode(0x10)); + Assert.IsTrue(result.Capabilities.SupportsVcpCode(0x12)); + Assert.IsTrue(result.Capabilities.SupportsVcpCode(0x14)); + } + + [TestMethod] + public void Parse_NestedParenthesesInVcp_HandlesCorrectly() + { + // Arrange - VCP code 0x14 with nested discrete values + var input = "(vcp(14(01 05 08)))"; + + // Act + var result = MccsCapabilitiesParser.Parse(input); + + // Assert + Assert.IsTrue(result.Capabilities.SupportsVcpCode(0x14)); + var vcpInfo = result.Capabilities.GetVcpCodeInfo(0x14); + Assert.IsNotNull(vcpInfo); + Assert.AreEqual(3, vcpInfo.Value.SupportedValues.Count); + } + + [TestMethod] + public void Parse_MultipleVcpCodesWithMixedFormats_ParsesAll() + { + // Arrange - Mixed: some with values, some without + var input = "(vcp(10 12 14(01 05) 16 60(0F 11)))"; + + // Act + var result = MccsCapabilitiesParser.Parse(input); + + // Assert + Assert.AreEqual(5, result.Capabilities.SupportedVcpCodes.Count); + + // Continuous codes (no discrete values) + var brightness = result.Capabilities.GetVcpCodeInfo(0x10); + Assert.IsTrue(brightness?.IsContinuous ?? false); + + var contrast = result.Capabilities.GetVcpCodeInfo(0x12); + Assert.IsTrue(contrast?.IsContinuous ?? false); + + // Discrete codes (with values) + var colorPreset = result.Capabilities.GetVcpCodeInfo(0x14); + Assert.IsTrue(colorPreset?.HasDiscreteValues ?? false); + Assert.AreEqual(2, colorPreset?.SupportedValues.Count); + + var inputSource = result.Capabilities.GetVcpCodeInfo(0x60); + Assert.IsTrue(inputSource?.HasDiscreteValues ?? false); + Assert.AreEqual(2, inputSource?.SupportedValues.Count); + } + + [TestMethod] + public void Parse_UnknownSegments_DoesNotFail() + { + // Arrange - Contains unknown segments like mswhql and asset_eep + var input = "(prot(monitor)mswhql(1)asset_eep(40)vcp(10))"; + + // Act + var result = MccsCapabilitiesParser.Parse(input); + + // Assert + Assert.IsFalse(result.HasErrors); + Assert.AreEqual("monitor", result.Capabilities.Protocol); + Assert.IsTrue(result.Capabilities.SupportsVcpCode(0x10)); + } + + [TestMethod] + public void Parse_ExtraWhitespace_HandlesCorrectly() + { + // Arrange - Extra spaces everywhere + var input = "( prot( monitor ) type( lcd ) vcp( 10 12 14( 01 05 ) ) )"; + + // Act + var result = MccsCapabilitiesParser.Parse(input); + + // Assert + Assert.AreEqual("monitor", result.Capabilities.Protocol); + Assert.AreEqual("lcd", result.Capabilities.Type); + Assert.AreEqual(3, result.Capabilities.SupportedVcpCodes.Count); + } + + [TestMethod] + public void Parse_LowercaseHex_ParsesCorrectly() + { + // Arrange - All lowercase hex + var input = "(cmds(01 0c e3 f3)vcp(10 ac ae))"; + + // Act + var result = MccsCapabilitiesParser.Parse(input); + + // Assert + CollectionAssert.Contains(result.Capabilities.SupportedCommands, (byte)0xE3); + CollectionAssert.Contains(result.Capabilities.SupportedCommands, (byte)0xF3); + Assert.IsTrue(result.Capabilities.SupportsVcpCode(0xAC)); + Assert.IsTrue(result.Capabilities.SupportsVcpCode(0xAE)); + } + + [TestMethod] + public void Parse_MixedCaseHex_ParsesCorrectly() + { + // Arrange - Mixed case hex + var input = "(vcp(Aa Bb cC Dd))"; + + // Act + var result = MccsCapabilitiesParser.Parse(input); + + // Assert + Assert.IsTrue(result.Capabilities.SupportsVcpCode(0xAA)); + Assert.IsTrue(result.Capabilities.SupportsVcpCode(0xBB)); + Assert.IsTrue(result.Capabilities.SupportsVcpCode(0xCC)); + Assert.IsTrue(result.Capabilities.SupportsVcpCode(0xDD)); + } + + [TestMethod] + public void Parse_MalformedInput_ReturnsPartialResults() + { + // Arrange - Missing closing paren for vcp section + var input = "(prot(monitor)vcp(10 12"; + + // Act + var result = MccsCapabilitiesParser.Parse(input); + + // Assert - Should still parse what it can + Assert.AreEqual("monitor", result.Capabilities.Protocol); + + // VCP codes should still be parsed + Assert.IsTrue(result.Capabilities.SupportsVcpCode(0x10)); + Assert.IsTrue(result.Capabilities.SupportsVcpCode(0x12)); + } + + [TestMethod] + public void Parse_InvalidHexInVcp_SkipsAndContinues() + { + // Arrange - Contains invalid hex "GG" + var input = "(vcp(10 GG 12 14))"; + + // Act + var result = MccsCapabilitiesParser.Parse(input); + + // Assert - Should skip invalid and parse valid codes + Assert.IsTrue(result.Capabilities.SupportsVcpCode(0x10)); + Assert.IsTrue(result.Capabilities.SupportsVcpCode(0x12)); + Assert.IsTrue(result.Capabilities.SupportsVcpCode(0x14)); + Assert.AreEqual(3, result.Capabilities.SupportedVcpCodes.Count); + } + + [TestMethod] + public void Parse_SingleCharacterHex_Skipped() + { + // Arrange - Single char "A" is not valid (need 2 chars) + var input = "(vcp(10 A 12))"; + + // Act + var result = MccsCapabilitiesParser.Parse(input); + + // Assert - Should only have 10 and 12 + Assert.AreEqual(2, result.Capabilities.SupportedVcpCodes.Count); + Assert.IsTrue(result.Capabilities.SupportsVcpCode(0x10)); + Assert.IsTrue(result.Capabilities.SupportsVcpCode(0x12)); + } + + [TestMethod] + public void GetVcpCodesAsHexStrings_ReturnsSortedList() + { + // Arrange + var result = MccsCapabilitiesParser.Parse("(vcp(60 10 14 12))"); + + // Act + var hexStrings = result.Capabilities.GetVcpCodesAsHexStrings(); + + // Assert - Should be sorted + Assert.AreEqual(4, hexStrings.Count); + Assert.AreEqual("0x10", hexStrings[0]); + Assert.AreEqual("0x12", hexStrings[1]); + Assert.AreEqual("0x14", hexStrings[2]); + Assert.AreEqual("0x60", hexStrings[3]); + } + + [TestMethod] + public void GetSortedVcpCodes_ReturnsSortedEnumerable() + { + // Arrange + var result = MccsCapabilitiesParser.Parse("(vcp(60 10 14 12))"); + + // Act + var sortedCodes = result.Capabilities.GetSortedVcpCodes().ToList(); + + // Assert + Assert.AreEqual(0x10, sortedCodes[0].Code); + Assert.AreEqual(0x12, sortedCodes[1].Code); + Assert.AreEqual(0x14, sortedCodes[2].Code); + Assert.AreEqual(0x60, sortedCodes[3].Code); + } + + [TestMethod] + public void HasDiscreteValues_ContinuousCode_ReturnsFalse() + { + // Arrange + var result = MccsCapabilitiesParser.Parse("(vcp(10))"); + + // Act & Assert + Assert.IsFalse(result.Capabilities.HasDiscreteValues(0x10)); + } + + [TestMethod] + public void HasDiscreteValues_DiscreteCode_ReturnsTrue() + { + // Arrange + var result = MccsCapabilitiesParser.Parse("(vcp(60(01 11)))"); + + // Act & Assert + Assert.IsTrue(result.Capabilities.HasDiscreteValues(0x60)); + } + + [TestMethod] + public void GetSupportedValues_DiscreteCode_ReturnsValues() + { + // Arrange + var result = MccsCapabilitiesParser.Parse("(vcp(60(01 11 0F)))"); + + // Act + var values = result.Capabilities.GetSupportedValues(0x60); + + // Assert + Assert.IsNotNull(values); + Assert.AreEqual(3, values.Count); + Assert.IsTrue(values.Contains(0x01)); + Assert.IsTrue(values.Contains(0x11)); + Assert.IsTrue(values.Contains(0x0F)); + } + + [TestMethod] + public void IsValid_ValidCapabilities_ReturnsTrue() + { + // Arrange & Act + var result = MccsCapabilitiesParser.Parse(DellU3011Capabilities); + + // Assert + Assert.IsTrue(result.IsValid); + Assert.IsFalse(result.HasErrors); + } + + [TestMethod] + public void IsValid_EmptyVcpCodes_ReturnsFalse() + { + // Arrange & Act + var result = MccsCapabilitiesParser.Parse("(prot(monitor)type(lcd))"); + + // Assert - No VCP codes = not valid + Assert.IsFalse(result.IsValid); + } + + [TestMethod] + public void Capabilities_RawProperty_ContainsOriginalString() + { + // Arrange & Act + var result = MccsCapabilitiesParser.Parse(SimpleCapabilities); + + // Assert + Assert.AreEqual(SimpleCapabilities, result.Capabilities.Raw); + } + + [TestMethod] + public void Parse_Window1Segment_ParsesCorrectly() + { + // Arrange - Full window segment with all fields + var input = "(vcp(10)window1(type(PIP) area(25 25 1895 1175) max(640 480) min(10 10) window(10)))"; + + // Act + var result = MccsCapabilitiesParser.Parse(input); + + // Assert + Assert.IsTrue(result.Capabilities.HasWindowSupport); + Assert.AreEqual(1, result.Capabilities.Windows.Count); + + var window = result.Capabilities.Windows[0]; + Assert.AreEqual(1, window.WindowNumber); + Assert.AreEqual("PIP", window.Type); + Assert.AreEqual(25, window.Area.X1); + Assert.AreEqual(25, window.Area.Y1); + Assert.AreEqual(1895, window.Area.X2); + Assert.AreEqual(1175, window.Area.Y2); + Assert.AreEqual(640, window.MaxSize.Width); + Assert.AreEqual(480, window.MaxSize.Height); + Assert.AreEqual(10, window.MinSize.Width); + Assert.AreEqual(10, window.MinSize.Height); + Assert.AreEqual(10, window.WindowId); + } + + [TestMethod] + public void Parse_MultipleWindows_ParsesAll() + { + // Arrange - Two windows (PIP and PBP) + var input = "(window1(type(PIP) area(0 0 640 480))window2(type(PBP) area(640 0 1280 480)))"; + + // Act + var result = MccsCapabilitiesParser.Parse(input); + + // Assert + Assert.IsTrue(result.Capabilities.HasWindowSupport); + Assert.AreEqual(2, result.Capabilities.Windows.Count); + + var window1 = result.Capabilities.Windows[0]; + Assert.AreEqual(1, window1.WindowNumber); + Assert.AreEqual("PIP", window1.Type); + Assert.AreEqual(0, window1.Area.X1); + Assert.AreEqual(640, window1.Area.X2); + + var window2 = result.Capabilities.Windows[1]; + Assert.AreEqual(2, window2.WindowNumber); + Assert.AreEqual("PBP", window2.Type); + Assert.AreEqual(640, window2.Area.X1); + Assert.AreEqual(1280, window2.Area.X2); + } + + [TestMethod] + public void Parse_WindowWithMissingFields_HandlesGracefully() + { + // Arrange - Window with only type and area (missing max, min, window) + var input = "(window1(type(PIP) area(0 0 640 480)))"; + + // Act + var result = MccsCapabilitiesParser.Parse(input); + + // Assert + Assert.IsTrue(result.Capabilities.HasWindowSupport); + Assert.AreEqual(1, result.Capabilities.Windows.Count); + + var window = result.Capabilities.Windows[0]; + Assert.AreEqual(1, window.WindowNumber); + Assert.AreEqual("PIP", window.Type); + Assert.AreEqual(640, window.Area.X2); + Assert.AreEqual(480, window.Area.Y2); + + // Default values for missing fields + Assert.AreEqual(0, window.MaxSize.Width); + Assert.AreEqual(0, window.MinSize.Width); + Assert.AreEqual(0, window.WindowId); + } + + [TestMethod] + public void Parse_WindowWithOnlyType_ParsesType() + { + // Arrange + var input = "(window1(type(PBP)))"; + + // Act + var result = MccsCapabilitiesParser.Parse(input); + + // Assert + Assert.IsTrue(result.Capabilities.HasWindowSupport); + Assert.AreEqual(1, result.Capabilities.Windows.Count); + Assert.AreEqual("PBP", result.Capabilities.Windows[0].Type); + } + + [TestMethod] + public void Parse_NoWindowSegment_HasWindowSupportFalse() + { + // Arrange + var input = "(prot(monitor)vcp(10 12))"; + + // Act + var result = MccsCapabilitiesParser.Parse(input); + + // Assert + Assert.IsFalse(result.Capabilities.HasWindowSupport); + Assert.AreEqual(0, result.Capabilities.Windows.Count); + } + + [TestMethod] + public void Parse_WindowAreaDimensions_CalculatesCorrectly() + { + // Arrange + var input = "(window1(area(100 200 500 600)))"; + + // Act + var result = MccsCapabilitiesParser.Parse(input); + + // Assert + var area = result.Capabilities.Windows[0].Area; + Assert.AreEqual(400, area.Width); // 500 - 100 + Assert.AreEqual(400, area.Height); // 600 - 200 + } + + [TestMethod] + public void Parse_RealWorldMccsWindowExample_ParsesCorrectly() + { + // Arrange - Example from MCCS 2.2a specification + var input = "(prot(display)type(lcd)model(PD3220U)cmds(01 02 03)vcp(10 12)mccs_ver(2.2)window1(type(PIP) area(25 25 1895 1175) max(640 480) min(10 10) window(10)))"; + + // Act + var result = MccsCapabilitiesParser.Parse(input); + + // Assert + Assert.AreEqual("lcd", result.Capabilities.Type); + Assert.AreEqual("PD3220U", result.Capabilities.Model); + Assert.AreEqual("2.2", result.Capabilities.MccsVersion); + Assert.IsTrue(result.Capabilities.HasWindowSupport); + Assert.AreEqual("PIP", result.Capabilities.Windows[0].Type); + } + + [TestMethod] + public void Parse_WindowWithExtraSpaces_HandlesCorrectly() + { + // Arrange - Extra spaces in content + var input = "(window1( type( PIP ) area( 0 0 640 480 ) ))"; + + // Act + var result = MccsCapabilitiesParser.Parse(input); + + // Assert + Assert.IsTrue(result.Capabilities.HasWindowSupport); + Assert.AreEqual("PIP", result.Capabilities.Windows[0].Type); + Assert.AreEqual(640, result.Capabilities.Windows[0].Area.X2); + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib.UnitTests/PowerDisplay.Lib.UnitTests.csproj b/src/modules/powerdisplay/PowerDisplay.Lib.UnitTests/PowerDisplay.Lib.UnitTests.csproj new file mode 100644 index 0000000000..fb7e2474db --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib.UnitTests/PowerDisplay.Lib.UnitTests.csproj @@ -0,0 +1,41 @@ + + + + + + + false + true + PowerDisplay.UnitTests + x64;ARM64 + false + false + $(SolutionDir)$(Platform)\$(Configuration)\tests\PowerDisplay.Lib.UnitTests\ + enable + + + + + + + + + + + + + + runtime + + + + runtime + + + + + + + diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/DDC/DdcCiController.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/DDC/DdcCiController.cs new file mode 100644 index 0000000000..e268045624 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/DDC/DdcCiController.cs @@ -0,0 +1,725 @@ +// 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.Linq; +using System.Threading; +using System.Threading.Tasks; +using ManagedCommon; +using Polly; +using Polly.Retry; +using PowerDisplay.Common.Interfaces; +using PowerDisplay.Common.Models; +using PowerDisplay.Common.Utils; +using static PowerDisplay.Common.Drivers.NativeConstants; +using static PowerDisplay.Common.Drivers.NativeDelegates; +using static PowerDisplay.Common.Drivers.PInvoke; +using Monitor = PowerDisplay.Common.Models.Monitor; + +// Type aliases matching Windows API naming conventions for better readability when working with native structures. +// These uppercase aliases are used consistently throughout this file to match Win32 API documentation. +using MONITORINFOEX = PowerDisplay.Common.Drivers.MonitorInfoEx; +using PHYSICAL_MONITOR = PowerDisplay.Common.Drivers.PhysicalMonitor; + +namespace PowerDisplay.Common.Drivers.DDC +{ + /// + /// DDC/CI monitor controller for controlling external monitors + /// + public partial class DdcCiController : IMonitorController, IDisposable + { + /// + /// Represents a candidate monitor discovered during Phase 1 of monitor enumeration. + /// + /// Physical monitor handle for DDC/CI communication + /// Native physical monitor structure with description + /// Display info from QueryDisplayConfig (EdidId, FriendlyName, MonitorNumber) + private readonly record struct CandidateMonitor( + IntPtr Handle, + PHYSICAL_MONITOR PhysicalMonitor, + MonitorDisplayInfo MonitorInfo); + + /// + /// Delay between retry attempts for DDC/CI operations (in milliseconds) + /// + private const int RetryDelayMs = 100; + + /// + /// Retry pipeline for getting capabilities string length (3 retries). + /// + private static readonly ResiliencePipeline CapabilitiesLengthRetryPipeline = + new ResiliencePipelineBuilder() + .AddRetry(new RetryStrategyOptions + { + MaxRetryAttempts = 2, // 2 retries = 3 total attempts + Delay = TimeSpan.FromMilliseconds(RetryDelayMs), + ShouldHandle = new PredicateBuilder().HandleResult(len => len == 0), + OnRetry = static args => + { + Logger.LogWarning($"[Retry] GetCapabilitiesStringLength returned invalid result on attempt {args.AttemptNumber + 1}, retrying..."); + return default; + }, + }) + .Build(); + + /// + /// Retry pipeline for getting capabilities string (5 retries). + /// + private static readonly ResiliencePipeline CapabilitiesStringRetryPipeline = + new ResiliencePipelineBuilder() + .AddRetry(new RetryStrategyOptions + { + MaxRetryAttempts = 4, // 4 retries = 5 total attempts + Delay = TimeSpan.FromMilliseconds(RetryDelayMs), + ShouldHandle = new PredicateBuilder().HandleResult(static str => string.IsNullOrEmpty(str)), + OnRetry = static args => + { + Logger.LogWarning($"[Retry] GetCapabilitiesString returned invalid result on attempt {args.AttemptNumber + 1}, retrying..."); + return default; + }, + }) + .Build(); + + private readonly PhysicalMonitorHandleManager _handleManager = new(); + private readonly MonitorDiscoveryHelper _discoveryHelper; + + private bool _disposed; + + public DdcCiController() + { + _discoveryHelper = new MonitorDiscoveryHelper(); + } + + public string Name => "DDC/CI Monitor Controller"; + + /// + /// Get monitor brightness using VCP code 0x10 + /// + public async Task GetBrightnessAsync(Monitor monitor, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(monitor); + return await GetVcpFeatureAsync(monitor, VcpCodeBrightness, cancellationToken); + } + + /// + /// Set monitor brightness using VCP code 0x10 + /// + public Task SetBrightnessAsync(Monitor monitor, int brightness, CancellationToken cancellationToken = default) + => SetVcpFeatureAsync(monitor, NativeConstants.VcpCodeBrightness, brightness, cancellationToken); + + /// + /// Set monitor contrast + /// + public Task SetContrastAsync(Monitor monitor, int contrast, CancellationToken cancellationToken = default) + => SetVcpFeatureAsync(monitor, NativeConstants.VcpCodeContrast, contrast, cancellationToken); + + /// + /// Set monitor volume + /// + public Task SetVolumeAsync(Monitor monitor, int volume, CancellationToken cancellationToken = default) + => SetVcpFeatureAsync(monitor, NativeConstants.VcpCodeVolume, volume, cancellationToken); + + /// + /// Get monitor color temperature using VCP code 0x14 (Select Color Preset) + /// Returns the raw VCP preset value (e.g., 0x05 for 6500K), not Kelvin temperature + /// + public async Task GetColorTemperatureAsync(Monitor monitor, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(monitor); + return await GetVcpFeatureAsync(monitor, VcpCodeSelectColorPreset, cancellationToken); + } + + /// + /// Set monitor color temperature using VCP code 0x14 (Select Color Preset) + /// + public Task SetColorTemperatureAsync(Monitor monitor, int colorTemperature, CancellationToken cancellationToken = default) + => SetVcpFeatureAsync(monitor, VcpCodeSelectColorPreset, colorTemperature, cancellationToken); + + /// + /// Get current input source using VCP code 0x60 + /// Returns the raw VCP value (e.g., 0x11 for HDMI-1) + /// + public async Task GetInputSourceAsync(Monitor monitor, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(monitor); + return await GetVcpFeatureAsync(monitor, VcpCodeInputSource, cancellationToken); + } + + /// + /// Set input source using VCP code 0x60 + /// + public Task SetInputSourceAsync(Monitor monitor, int inputSource, CancellationToken cancellationToken = default) + => SetVcpFeatureAsync(monitor, VcpCodeInputSource, inputSource, cancellationToken); + + /// + /// Set power state using VCP code 0xD6 (Power Mode). + /// Values: 0x01=On, 0x02=Standby, 0x03=Suspend, 0x04=Off(DPM), 0x05=Off(Hard). + /// Note: Setting any value other than 0x01 (On) will turn off the display. + /// + public Task SetPowerStateAsync(Monitor monitor, int powerState, CancellationToken cancellationToken = default) + => SetVcpFeatureAsync(monitor, VcpCodePowerMode, powerState, cancellationToken); + + /// + /// Get current power state using VCP code 0xD6 (Power Mode). + /// Returns the raw VCP value (0x01=On, 0x02=Standby, etc.) + /// + public async Task GetPowerStateAsync(Monitor monitor, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(monitor); + return await GetVcpFeatureAsync(monitor, VcpCodePowerMode, cancellationToken); + } + + /// + /// Get monitor capabilities string with retry logic. + /// Uses cached CapabilitiesRaw if available to avoid slow I2C operations. + /// + public async Task GetCapabilitiesStringAsync(Monitor monitor, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(monitor); + + // Check if capabilities are already cached + if (!string.IsNullOrEmpty(monitor.CapabilitiesRaw)) + { + return monitor.CapabilitiesRaw; + } + + return await Task.Run( + () => + { + if (monitor.Handle == IntPtr.Zero) + { + return string.Empty; + } + + try + { + // Step 1: Get capabilities string length with retry + var length = CapabilitiesLengthRetryPipeline.Execute(() => + { + if (GetCapabilitiesStringLength(monitor.Handle, out uint len) && len > 0) + { + return len; + } + + return 0u; + }); + + if (length == 0) + { + Logger.LogWarning("[Retry] GetCapabilitiesStringLength failed after 3 attempts"); + return string.Empty; + } + + // Step 2: Get actual capabilities string with retry + var capsString = CapabilitiesStringRetryPipeline.Execute( + () => TryGetCapabilitiesString(monitor.Handle, length)); + + if (!string.IsNullOrEmpty(capsString)) + { + return capsString; + } + + Logger.LogWarning("[Retry] GetCapabilitiesString failed after 5 attempts"); + } + catch (Exception ex) + { + Logger.LogError($"Exception getting capabilities string: {ex.Message}"); + } + + return string.Empty; + }, + cancellationToken); + } + + /// + /// Try to get capabilities string from monitor handle. + /// + private string? TryGetCapabilitiesString(IntPtr handle, uint length) + { + var buffer = System.Runtime.InteropServices.Marshal.AllocHGlobal((int)length); + try + { + if (CapabilitiesRequestAndCapabilitiesReply(handle, buffer, length)) + { + return System.Runtime.InteropServices.Marshal.PtrToStringAnsi(buffer); + } + + return null; + } + finally + { + System.Runtime.InteropServices.Marshal.FreeHGlobal(buffer); + } + } + + /// + /// Discover supported monitors using a three-phase approach: + /// Phase 1: Enumerate and collect candidate monitors with their handles + /// Phase 2: Fetch DDC/CI capabilities in parallel (slow I2C operations) + /// Phase 3: Create Monitor objects for valid DDC/CI monitors + /// + public async Task> DiscoverMonitorsAsync(CancellationToken cancellationToken = default) + { + try + { + // Get monitor display info from QueryDisplayConfig, keyed by device path (unique per target) + var allMonitorDisplayInfo = DdcCiNative.GetAllMonitorDisplayInfo(); + + // Phase 1: Collect candidate monitors + var monitorHandles = EnumerateMonitorHandles(); + if (monitorHandles.Count == 0) + { + return Enumerable.Empty(); + } + + var candidateMonitors = await CollectCandidateMonitorsAsync( + monitorHandles, allMonitorDisplayInfo, cancellationToken); + + if (candidateMonitors.Count == 0) + { + return Enumerable.Empty(); + } + + // Phase 2: Fetch capabilities in parallel + var fetchResults = await FetchCapabilitiesInParallelAsync( + candidateMonitors, cancellationToken); + + // Phase 3: Create monitor objects + return CreateValidMonitors(fetchResults); + } + catch (Exception ex) + { + Logger.LogError($"DDC: DiscoverMonitorsAsync exception: {ex.Message}\nStack: {ex.StackTrace}"); + return Enumerable.Empty(); + } + } + + /// + /// Enumerate all logical monitor handles using Win32 API. + /// + private List EnumerateMonitorHandles() + { + var handles = new List(); + + bool EnumProc(IntPtr hMonitor, IntPtr hdcMonitor, IntPtr lprcMonitor, IntPtr dwData) + { + handles.Add(hMonitor); + return true; + } + + if (!EnumDisplayMonitors(IntPtr.Zero, IntPtr.Zero, EnumProc, IntPtr.Zero)) + { + Logger.LogWarning("DDC: EnumDisplayMonitors failed"); + } + + return handles; + } + + /// + /// Get GDI device name for a monitor handle (e.g., "\\.\DISPLAY1"). + /// + private unsafe string? GetGdiDeviceName(IntPtr hMonitor) + { + var monitorInfo = new MONITORINFOEX { CbSize = (uint)sizeof(MONITORINFOEX) }; + if (GetMonitorInfo(hMonitor, &monitorInfo)) + { + return monitorInfo.GetDeviceName(); + } + + return null; + } + + /// + /// Phase 1: Collect all candidate monitors with their physical handles. + /// Matches physical monitors with MonitorDisplayInfo using GDI device name and friendly name. + /// Supports mirror mode where multiple physical monitors share the same GDI name. + /// + private async Task> CollectCandidateMonitorsAsync( + List monitorHandles, + Dictionary allMonitorDisplayInfo, + CancellationToken cancellationToken) + { + var candidates = new List(); + + foreach (var hMonitor in monitorHandles) + { + // Get GDI device name for this monitor (e.g., "\\.\DISPLAY1") + var gdiDeviceName = GetGdiDeviceName(hMonitor); + if (string.IsNullOrEmpty(gdiDeviceName)) + { + Logger.LogWarning($"DDC: Failed to get GDI device name for hMonitor 0x{hMonitor:X}"); + continue; + } + + var physicalMonitors = await GetPhysicalMonitorsWithRetryAsync(hMonitor, cancellationToken); + if (physicalMonitors == null || physicalMonitors.Length == 0) + { + Logger.LogWarning($"DDC: Failed to get physical monitors for {gdiDeviceName} after retries"); + continue; + } + + // Find all MonitorDisplayInfo entries that match this GDI device name + // In mirror mode, multiple targets share the same GDI name + var matchingInfos = allMonitorDisplayInfo.Values + .Where(info => string.Equals(info.GdiDeviceName, gdiDeviceName, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + if (matchingInfos.Count == 0) + { + Logger.LogWarning($"DDC: No QueryDisplayConfig info for {gdiDeviceName}, skipping"); + continue; + } + + for (int i = 0; i < physicalMonitors.Length; i++) + { + var physicalMonitor = physicalMonitors[i]; + + if (i >= matchingInfos.Count) + { + Logger.LogWarning($"DDC: Physical monitor index {i} exceeds available QueryDisplayConfig entries ({matchingInfos.Count}) for {gdiDeviceName}"); + break; + } + + var monitorInfo = matchingInfos[i]; + + candidates.Add(new CandidateMonitor(physicalMonitor.HPhysicalMonitor, physicalMonitor, monitorInfo)); + } + } + + return candidates; + } + + /// + /// Phase 2: Fetch DDC/CI capabilities in parallel for all candidate monitors. + /// This is the slow I2C operation (~4s per monitor), but parallelization + /// significantly reduces total time when multiple monitors are connected. + /// + private async Task<(CandidateMonitor Candidate, DdcCiValidationResult Result)[]> FetchCapabilitiesInParallelAsync( + List candidates, + CancellationToken cancellationToken) + { + var tasks = candidates.Select(candidate => + Task.Run( + () => (Candidate: candidate, Result: DdcCiNative.FetchCapabilities(candidate.Handle)), + cancellationToken)); + + var results = await Task.WhenAll(tasks); + + return results; + } + + /// + /// Phase 3: Create Monitor objects for valid DDC/CI monitors. + /// A monitor is valid if it has capabilities with brightness support. + /// + private List CreateValidMonitors( + (CandidateMonitor Candidate, DdcCiValidationResult Result)[] fetchResults) + { + var monitors = new List(); + var newHandleMap = new Dictionary(); + + foreach (var (candidate, capResult) in fetchResults) + { + if (!capResult.IsValid) + { + continue; + } + + var monitor = _discoveryHelper.CreateMonitorFromPhysical( + candidate.PhysicalMonitor, + candidate.MonitorInfo); + + if (monitor == null) + { + continue; + } + + // Set capabilities data + if (!string.IsNullOrEmpty(capResult.CapabilitiesString)) + { + monitor.CapabilitiesRaw = capResult.CapabilitiesString; + } + + if (capResult.VcpCapabilitiesInfo != null) + { + monitor.VcpCapabilitiesInfo = capResult.VcpCapabilitiesInfo; + UpdateMonitorCapabilitiesFromVcp(monitor, capResult.VcpCapabilitiesInfo); + + // Initialize input source if supported + if (monitor.SupportsInputSource) + { + InitializeInputSource(monitor, candidate.Handle); + } + + // Initialize color temperature if supported + if (monitor.SupportsColorTemperature) + { + InitializeColorTemperature(monitor, candidate.Handle); + } + + // Initialize power state if supported + if (monitor.SupportsPowerState) + { + InitializePowerState(monitor, candidate.Handle); + } + + // Initialize contrast if supported + if (monitor.SupportsContrast) + { + InitializeContrast(monitor, candidate.Handle); + } + } + + // Initialize brightness (always supported for DDC/CI monitors) + InitializeBrightness(monitor, candidate.Handle); + + monitors.Add(monitor); + newHandleMap[monitor.Id] = candidate.Handle; + } + + _handleManager.UpdateHandleMap(newHandleMap); + return monitors; + } + + /// + /// Initialize input source value for a monitor using VCP 0x60. + /// + private static void InitializeInputSource(Monitor monitor, IntPtr handle) + { + if (TryGetVcpFeature(handle, VcpCodeInputSource, monitor.Id, out uint current, out uint _)) + { + monitor.CurrentInputSource = (int)current; + } + } + + /// + /// Initialize color temperature value for a monitor using VCP 0x14. + /// + private static void InitializeColorTemperature(Monitor monitor, IntPtr handle) + { + if (TryGetVcpFeature(handle, VcpCodeSelectColorPreset, monitor.Id, out uint current, out uint _)) + { + monitor.CurrentColorTemperature = (int)current; + } + } + + /// + /// Initialize power state value for a monitor using VCP 0xD6. + /// + private static void InitializePowerState(Monitor monitor, IntPtr handle) + { + if (TryGetVcpFeature(handle, VcpCodePowerMode, monitor.Id, out uint current, out uint _)) + { + monitor.CurrentPowerState = (int)current; + } + } + + /// + /// Initialize brightness value for a monitor using VCP 0x10. + /// + private static void InitializeBrightness(Monitor monitor, IntPtr handle) + { + if (TryGetVcpFeature(handle, VcpCodeBrightness, monitor.Id, out uint current, out uint max)) + { + var brightnessInfo = new VcpFeatureValue((int)current, 0, (int)max); + monitor.CurrentBrightness = brightnessInfo.ToPercentage(); + } + } + + /// + /// Initialize contrast value for a monitor using VCP 0x12. + /// + private static void InitializeContrast(Monitor monitor, IntPtr handle) + { + if (TryGetVcpFeature(handle, VcpCodeContrast, monitor.Id, out uint current, out uint max)) + { + var contrastInfo = new VcpFeatureValue((int)current, 0, (int)max); + monitor.CurrentContrast = contrastInfo.ToPercentage(); + } + } + + /// + /// Wrapper for GetVCPFeatureAndVCPFeatureReply that logs errors on failure. + /// + /// Physical monitor handle + /// VCP code to read + /// Monitor ID for logging (optional) + /// Output: current value + /// Output: maximum value + /// True if successful, false otherwise + private static bool TryGetVcpFeature(IntPtr handle, byte vcpCode, string? monitorId, out uint currentValue, out uint maxValue) + { + if (GetVCPFeatureAndVCPFeatureReply(handle, vcpCode, IntPtr.Zero, out currentValue, out maxValue)) + { + return true; + } + + var lastError = GetLastError(); + var monitorPrefix = string.IsNullOrEmpty(monitorId) ? string.Empty : $"[{monitorId}] "; + Logger.LogError($"{monitorPrefix}Failed to read VCP 0x{vcpCode:X2}, error code: {lastError}"); + return false; + } + + /// + /// Update monitor capability flags based on parsed VCP capabilities. + /// + private static void UpdateMonitorCapabilitiesFromVcp(Monitor monitor, VcpCapabilities vcpCaps) + { + // Check for Contrast support (VCP 0x12) + if (vcpCaps.SupportsVcpCode(VcpCodeContrast)) + { + monitor.Capabilities |= MonitorCapabilities.Contrast; + } + + // Check for Volume support (VCP 0x62) + if (vcpCaps.SupportsVcpCode(VcpCodeVolume)) + { + monitor.Capabilities |= MonitorCapabilities.Volume; + } + + // Check for Color Temperature support (VCP 0x14) + if (vcpCaps.SupportsVcpCode(VcpCodeSelectColorPreset)) + { + monitor.SupportsColorTemperature = true; + } + } + + /// + /// Get physical monitors with retry logic to handle Windows API occasionally returning NULL handles. + /// NULL handles are automatically filtered out by GetPhysicalMonitors; retry if any were filtered. + /// + /// Handle to the monitor + /// Cancellation token + /// Array of valid physical monitors, or null if failed after retries + private async Task GetPhysicalMonitorsWithRetryAsync( + IntPtr hMonitor, + CancellationToken cancellationToken) + { + const int maxRetries = 3; + const int retryDelayMs = 200; + + for (int attempt = 0; attempt < maxRetries; attempt++) + { + if (attempt > 0) + { + await Task.Delay(retryDelayMs, cancellationToken); + } + + var monitors = _discoveryHelper.GetPhysicalMonitors(hMonitor, out bool hasNullHandles); + + // Success: got valid monitors with no NULL handles filtered out + if (monitors != null && !hasNullHandles) + { + return monitors; + } + + // Got monitors but some had NULL handles - retry to see if API stabilizes + if (monitors != null && hasNullHandles && attempt < maxRetries - 1) + { + Logger.LogWarning($"DDC: Some monitors had NULL handles on attempt {attempt + 1}, will retry"); + continue; + } + + // No monitors returned - retry + if (monitors == null && attempt < maxRetries - 1) + { + Logger.LogWarning($"DDC: GetPhysicalMonitors returned null on attempt {attempt + 1}, will retry"); + continue; + } + + // Last attempt - return whatever we have (may have NULL handles filtered) + if (monitors != null && hasNullHandles) + { + Logger.LogWarning($"DDC: NULL handles still present after {maxRetries} attempts, using filtered result"); + } + + return monitors; + } + + return null; + } + + /// + /// Generic method to get VCP feature value. + /// + /// Monitor to query + /// VCP code to read + /// Cancellation token + private async Task GetVcpFeatureAsync( + Monitor monitor, + byte vcpCode, + CancellationToken cancellationToken = default) + { + return await Task.Run( + () => + { + if (monitor.Handle == IntPtr.Zero) + { + return VcpFeatureValue.Invalid; + } + + if (TryGetVcpFeature(monitor.Handle, vcpCode, monitor.Id, out uint current, out uint max)) + { + return new VcpFeatureValue((int)current, 0, (int)max); + } + + return VcpFeatureValue.Invalid; + }, + cancellationToken); + } + + /// + /// Generic method to set VCP feature value directly. + /// + private Task SetVcpFeatureAsync( + Monitor monitor, + byte vcpCode, + int value, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(monitor); + + return Task.Run( + () => + { + if (monitor.Handle == IntPtr.Zero) + { + return MonitorOperationResult.Failure("Invalid monitor handle"); + } + + try + { + if (SetVCPFeature(monitor.Handle, vcpCode, (uint)value)) + { + return MonitorOperationResult.Success(); + } + + var lastError = GetLastError(); + return MonitorOperationResult.Failure($"Failed to set VCP 0x{vcpCode:X2}", (int)lastError); + } + catch (Exception ex) + { + return MonitorOperationResult.Failure($"Exception setting VCP 0x{vcpCode:X2}: {ex.Message}"); + } + }, + cancellationToken); + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (!_disposed && disposing) + { + _handleManager?.Dispose(); + _disposed = true; + } + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/DDC/DdcCiNative.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/DDC/DdcCiNative.cs new file mode 100644 index 0000000000..8b5dbf49fb --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/DDC/DdcCiNative.cs @@ -0,0 +1,277 @@ +// 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.Runtime.InteropServices; +using Windows.Win32.Foundation; +using static PowerDisplay.Common.Drivers.NativeConstants; +using static PowerDisplay.Common.Drivers.PInvoke; + +namespace PowerDisplay.Common.Drivers.DDC +{ + /// + /// DDC/CI native API wrapper + /// + public static class DdcCiNative + { + /// + /// Fetches VCP capabilities string from a monitor and returns a validation result. + /// This is the slow I2C operation (~4 seconds per monitor) that should only be done once. + /// The result is cached regardless of success or failure. + /// + /// Physical monitor handle + /// Validation result with capabilities data (or failure status) + public static DdcCiValidationResult FetchCapabilities(IntPtr hPhysicalMonitor) + { + if (hPhysicalMonitor == IntPtr.Zero) + { + return DdcCiValidationResult.Invalid; + } + + try + { + // Try to get capabilities string (slow I2C operation) + var capsString = TryGetCapabilitiesString(hPhysicalMonitor); + if (string.IsNullOrEmpty(capsString)) + { + return DdcCiValidationResult.Invalid; + } + + // Parse the capabilities string + var parseResult = Utils.MccsCapabilitiesParser.Parse(capsString); + var capabilities = parseResult.Capabilities; + if (capabilities == null || capabilities.SupportedVcpCodes.Count == 0) + { + return DdcCiValidationResult.Invalid; + } + + // Check if brightness (VCP 0x10) is supported - determines DDC/CI validity + bool supportsBrightness = capabilities.SupportsVcpCode(NativeConstants.VcpCodeBrightness); + return new DdcCiValidationResult(supportsBrightness, capsString, capabilities); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + return DdcCiValidationResult.Invalid; + } + } + + /// + /// Try to get capabilities string from a physical monitor handle. + /// + /// Physical monitor handle + /// Capabilities string, or null if failed + private static string? TryGetCapabilitiesString(IntPtr hPhysicalMonitor) + { + if (hPhysicalMonitor == IntPtr.Zero) + { + return null; + } + + try + { + // Get capabilities string length + if (!GetCapabilitiesStringLength(hPhysicalMonitor, out uint length) || length == 0) + { + return null; + } + + // Allocate buffer and get capabilities string + var buffer = Marshal.AllocHGlobal((int)length); + try + { + if (!CapabilitiesRequestAndCapabilitiesReply(hPhysicalMonitor, buffer, length)) + { + return null; + } + + return Marshal.PtrToStringAnsi(buffer); + } + finally + { + Marshal.FreeHGlobal(buffer); + } + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + return null; + } + } + + /// + /// Gets GDI device name for a source (e.g., "\\.\DISPLAY1"). + /// + /// Adapter ID + /// Source ID + /// GDI device name, or null if retrieval fails + private static unsafe string? GetSourceGdiDeviceName(LUID adapterId, uint sourceId) + { + try + { + var sourceName = new DisplayConfigSourceDeviceName + { + Header = new DisplayConfigDeviceInfoHeader + { + Type = DisplayconfigDeviceInfoGetSourceName, + Size = (uint)sizeof(DisplayConfigSourceDeviceName), + AdapterId = adapterId, + Id = sourceId, + }, + }; + + var result = DisplayConfigGetDeviceInfo(&sourceName); + if (result == 0) + { + return sourceName.GetViewGdiDeviceName(); + } + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + } + + return null; + } + + /// + /// Gets friendly name, EDID ID, and device path for a monitor target. + /// + /// Adapter ID + /// Target ID + /// Tuple of (friendlyName, edidId, devicePath), any may be null if retrieval fails + private static unsafe (string? FriendlyName, string? EdidId, string? DevicePath) GetTargetDeviceInfo(LUID adapterId, uint targetId) + { + try + { + var deviceName = new DisplayConfigTargetDeviceName + { + Header = new DisplayConfigDeviceInfoHeader + { + Type = DisplayconfigDeviceInfoGetTargetName, + Size = (uint)sizeof(DisplayConfigTargetDeviceName), + AdapterId = adapterId, + Id = targetId, + }, + }; + + var result = DisplayConfigGetDeviceInfo(&deviceName); + if (result == 0) + { + // Extract friendly name + var friendlyName = deviceName.GetMonitorFriendlyDeviceName(); + + // Extract device path (unique per target, used as key) + var devicePath = deviceName.GetMonitorDevicePath(); + + // Extract EDID ID from EDID data + var manufacturerId = deviceName.EdidManufactureId; + var manufactureCode = ConvertManufactureIdToString(manufacturerId); + var productCode = deviceName.EdidProductCodeId.ToString("X4", System.Globalization.CultureInfo.InvariantCulture); + var edidId = $"{manufactureCode}{productCode}"; + + return (friendlyName, edidId, devicePath); + } + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + } + + return (null, null, null); + } + + /// + /// Converts manufacturer ID to 3-character manufacturer code + /// + /// Manufacturer ID + /// 3-character manufacturer code + private static string ConvertManufactureIdToString(ushort manufacturerId) + { + // EDID manufacturer ID requires byte order swap first + manufacturerId = (ushort)(((manufacturerId & 0xff00) >> 8) | ((manufacturerId & 0x00ff) << 8)); + + // Extract 3 5-bit characters (each character is A-Z, where A=1, B=2, ..., Z=26) + var char1 = (char)('A' - 1 + ((manufacturerId >> 0) & 0x1f)); + var char2 = (char)('A' - 1 + ((manufacturerId >> 5) & 0x1f)); + var char3 = (char)('A' - 1 + ((manufacturerId >> 10) & 0x1f)); + + // Combine characters in correct order + return $"{char3}{char2}{char1}"; + } + + /// + /// Gets complete information for all monitors, keyed by GDI device name (e.g., "\\.\DISPLAY1"). + /// This allows reliable matching with GetMonitorInfo results. + /// + /// Dictionary keyed by GDI device name containing monitor information + public static unsafe Dictionary GetAllMonitorDisplayInfo() + { + var monitorInfo = new Dictionary(StringComparer.OrdinalIgnoreCase); + + try + { + // Get buffer sizes + var result = GetDisplayConfigBufferSizes(QdcOnlyActivePaths, out uint pathCount, out uint modeCount); + if (result != 0) + { + return monitorInfo; + } + + // Allocate buffers + var paths = new DisplayConfigPathInfo[pathCount]; + var modes = new DisplayConfigModeInfo[modeCount]; + + // Query display configuration using fixed pointer + fixed (DisplayConfigPathInfo* pathsPtr = paths) + { + fixed (DisplayConfigModeInfo* modesPtr = modes) + { + result = QueryDisplayConfig(QdcOnlyActivePaths, ref pathCount, pathsPtr, ref modeCount, modesPtr, IntPtr.Zero); + if (result != 0) + { + return monitorInfo; + } + } + } + + // Get information for each path + // The path index corresponds to Windows Display Settings "Identify" number + for (int i = 0; i < pathCount; i++) + { + var path = paths[i]; + + // Get GDI device name from source info (e.g., "\\.\DISPLAY1") + var gdiDeviceName = GetSourceGdiDeviceName(path.SourceInfo.AdapterId, path.SourceInfo.Id); + if (string.IsNullOrEmpty(gdiDeviceName)) + { + continue; + } + + // Get target info (friendly name, EDID ID, device path) + var (friendlyName, edidId, devicePath) = GetTargetDeviceInfo(path.TargetInfo.AdapterId, path.TargetInfo.Id); + + // Use device path as key - unique per target, supports mirror mode + if (string.IsNullOrEmpty(devicePath)) + { + continue; + } + + monitorInfo[devicePath] = new MonitorDisplayInfo + { + DevicePath = devicePath, + GdiDeviceName = gdiDeviceName, + FriendlyName = friendlyName ?? string.Empty, + EdidId = edidId ?? string.Empty, + AdapterId = path.TargetInfo.AdapterId, + TargetId = path.TargetInfo.Id, + MonitorNumber = i + 1, // 1-based, matches Windows Display Settings + }; + } + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + } + + return monitorInfo; + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/DDC/DdcCiValidationResult.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/DDC/DdcCiValidationResult.cs new file mode 100644 index 0000000000..33cb3b7b5e --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/DDC/DdcCiValidationResult.cs @@ -0,0 +1,57 @@ +// 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. + +namespace PowerDisplay.Common.Drivers.DDC +{ + /// + /// DDC/CI validation result containing both validation status and cached capabilities data. + /// This allows reusing capabilities data retrieved during validation, avoiding duplicate I2C calls. + /// + public struct DdcCiValidationResult + { + /// + /// Gets a value indicating whether the monitor has a valid DDC/CI connection with brightness support. + /// + public bool IsValid { get; } + + /// + /// Gets the raw capabilities string retrieved during validation. + /// Null if retrieval failed. + /// + public string? CapabilitiesString { get; } + + /// + /// Gets the parsed VCP capabilities info retrieved during validation. + /// Null if parsing failed. + /// + public Models.VcpCapabilities? VcpCapabilitiesInfo { get; } + + /// + /// Gets a value indicating whether capabilities retrieval was attempted. + /// True means the result is from an actual attempt (success or failure). + /// + public bool WasAttempted { get; } + + /// + /// Initializes a new instance of the struct. + /// + public DdcCiValidationResult(bool isValid, string? capabilitiesString = null, Models.VcpCapabilities? vcpCapabilitiesInfo = null, bool wasAttempted = true) + { + IsValid = isValid; + CapabilitiesString = capabilitiesString; + VcpCapabilitiesInfo = vcpCapabilitiesInfo; + WasAttempted = wasAttempted; + } + + /// + /// Gets an invalid validation result with no cached data. + /// + public static DdcCiValidationResult Invalid => new(false, null, null, true); + + /// + /// Gets a result indicating validation was not attempted yet. + /// + public static DdcCiValidationResult NotAttempted => new(false, null, null, false); + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/DDC/MonitorDiscoveryHelper.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/DDC/MonitorDiscoveryHelper.cs new file mode 100644 index 0000000000..82d0240e80 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/DDC/MonitorDiscoveryHelper.cs @@ -0,0 +1,150 @@ +// 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 ManagedCommon; +using PowerDisplay.Common.Models; +using static PowerDisplay.Common.Drivers.NativeConstants; +using static PowerDisplay.Common.Drivers.PInvoke; +using PHYSICAL_MONITOR = PowerDisplay.Common.Drivers.PhysicalMonitor; + +namespace PowerDisplay.Common.Drivers.DDC +{ + /// + /// Helper class for discovering and creating monitor objects + /// + public class MonitorDiscoveryHelper + { + /// + /// Get physical monitors for a logical monitor. + /// Filters out any monitors with NULL handles (Windows API bug workaround). + /// + /// Handle to the logical monitor + /// Output: true if any NULL handles were filtered out + /// Array of valid physical monitors, or null if API call failed + internal PHYSICAL_MONITOR[]? GetPhysicalMonitors(IntPtr hMonitor, out bool hasNullHandles) + { + hasNullHandles = false; + + try + { + if (!GetNumberOfPhysicalMonitorsFromHMONITOR(hMonitor, out uint numMonitors)) + { + Logger.LogWarning($"GetPhysicalMonitors: GetNumberOfPhysicalMonitorsFromHMONITOR failed for 0x{hMonitor:X}"); + return null; + } + + if (numMonitors == 0) + { + Logger.LogWarning($"GetPhysicalMonitors: numMonitors is 0"); + return null; + } + + var physicalMonitors = new PHYSICAL_MONITOR[numMonitors]; + bool apiResult; + unsafe + { + fixed (PHYSICAL_MONITOR* ptr = physicalMonitors) + { + apiResult = GetPhysicalMonitorsFromHMONITOR(hMonitor, numMonitors, ptr); + } + } + + if (!apiResult) + { + Logger.LogWarning($"GetPhysicalMonitors: GetPhysicalMonitorsFromHMONITOR failed"); + return null; + } + + // Filter out NULL handles and log each physical monitor + var validMonitors = new List(); + for (int i = 0; i < numMonitors; i++) + { + IntPtr handle = physicalMonitors[i].HPhysicalMonitor; + + if (handle == IntPtr.Zero) + { + Logger.LogWarning($"GetPhysicalMonitors: Monitor [{i}] has NULL handle, filtering out"); + hasNullHandles = true; + continue; + } + + validMonitors.Add(physicalMonitors[i]); + } + + return validMonitors.Count > 0 ? validMonitors.ToArray() : null; + } + catch (Exception ex) + { + Logger.LogError($"GetPhysicalMonitors: Exception: {ex.Message}"); + return null; + } + } + + /// + /// Create Monitor object from physical monitor and display info. + /// Uses MonitorDisplayInfo directly from QueryDisplayConfig for stable identification. + /// Note: Brightness is not initialized here - MonitorManager handles brightness initialization + /// after discovery to avoid slow I2C operations during the discovery phase. + /// + /// Physical monitor structure with handle and description + /// Display info from QueryDisplayConfig (EdidId, FriendlyName, MonitorNumber) + internal Monitor? CreateMonitorFromPhysical( + PHYSICAL_MONITOR physicalMonitor, + MonitorDisplayInfo monitorInfo) + { + try + { + // Get EDID ID and friendly name directly from MonitorDisplayInfo + string edidId = monitorInfo.EdidId ?? string.Empty; + string name = physicalMonitor.GetDescription() ?? string.Empty; + + // Use FriendlyName from QueryDisplayConfig if available and not generic + if (!string.IsNullOrEmpty(monitorInfo.FriendlyName) && + !monitorInfo.FriendlyName.Contains("Generic")) + { + name = monitorInfo.FriendlyName; + } + + // Generate unique monitor Id: "DDC_{EdidId}_{MonitorNumber}" + string monitorId = !string.IsNullOrEmpty(edidId) + ? $"DDC_{edidId}_{monitorInfo.MonitorNumber}" + : $"DDC_Unknown_{monitorInfo.MonitorNumber}"; + + // If still no good name, use default value + if (string.IsNullOrEmpty(name) || name.Contains("Generic") || name.Contains("PnP")) + { + name = "External Display"; + } + + var monitor = new Monitor + { + Id = monitorId, + Name = name.Trim(), + CurrentBrightness = 50, // Default value, will be updated by MonitorManager after discovery + MinBrightness = 0, + MaxBrightness = 100, + IsAvailable = true, + Handle = physicalMonitor.HPhysicalMonitor, + Capabilities = MonitorCapabilities.DdcCi, + CommunicationMethod = "DDC/CI", + MonitorNumber = monitorInfo.MonitorNumber, + GdiDeviceName = monitorInfo.GdiDeviceName ?? string.Empty, + Orientation = DmdoDefault, // Orientation will be set separately if needed + }; + + // Note: Feature detection (brightness, contrast, color temp, volume) is now done + // in MonitorManager after capabilities string is retrieved and parsed. + // This ensures we rely on capabilities data rather than trial-and-error probing. + return monitor; + } + catch (Exception ex) + { + Logger.LogError($"DDC: CreateMonitorFromPhysical exception: {ex.Message}"); + return null; + } + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/DDC/MonitorDisplayInfo.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/DDC/MonitorDisplayInfo.cs new file mode 100644 index 0000000000..9faad9f18a --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/DDC/MonitorDisplayInfo.cs @@ -0,0 +1,50 @@ +// 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 Windows.Win32.Foundation; + +namespace PowerDisplay.Common.Drivers.DDC +{ + /// + /// Monitor display information structure + /// + public struct MonitorDisplayInfo + { + /// + /// Gets or sets the monitor device path (e.g., "\\?\DISPLAY#DELA1D8#..."). + /// This is unique per target and used as the primary key. + /// + public string DevicePath { get; set; } + + /// + /// Gets or sets the GDI device name (e.g., "\\.\DISPLAY1"). + /// This is used to match with GetMonitorInfo results from HMONITOR. + /// In mirror mode, multiple targets may share the same GDI name. + /// + public string GdiDeviceName { get; set; } + + /// + /// Gets or sets the friendly display name from EDID. + /// + public string FriendlyName { get; set; } + + /// + /// Gets or sets the EDID ID derived from manufacturer and product code. + /// Format: "{ManufacturerCode}{ProductCode}" (e.g., "GSM5C6D", "LEN4038"). + /// Note: This is NOT unique - same model monitors have the same EdidId. + /// + public string EdidId { get; set; } + + public LUID AdapterId { get; set; } + + public uint TargetId { get; set; } + + /// + /// Gets or sets the monitor number based on QueryDisplayConfig path index. + /// This matches the number shown in Windows Display Settings "Identify" feature. + /// 1-based index (paths[0] = 1, paths[1] = 2, etc.) + /// + public int MonitorNumber { get; set; } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/DDC/PhysicalMonitorHandleManager.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/DDC/PhysicalMonitorHandleManager.cs new file mode 100644 index 0000000000..c9482673d2 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/DDC/PhysicalMonitorHandleManager.cs @@ -0,0 +1,106 @@ +// 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.Concurrent; +using System.Collections.Generic; +using System.Linq; +using ManagedCommon; +using static PowerDisplay.Common.Drivers.PInvoke; + +namespace PowerDisplay.Common.Drivers.DDC +{ + /// + /// Manages physical monitor handles - reuse, cleanup, and validation + /// + public partial class PhysicalMonitorHandleManager : IDisposable + { + // Mapping: monitorId -> physical handle (thread-safe) + private readonly ConcurrentDictionary _monitorIdToHandleMap = new(); + private readonly object _handleLock = new(); + private bool _disposed; + + /// + /// Update the handle mapping with new handles + /// + public void UpdateHandleMap(Dictionary newHandleMap) + { + // Lock to ensure atomic update (cleanup + replace) + lock (_handleLock) + { + // Clean up unused handles before updating + CleanupUnusedHandles(newHandleMap); + + // Update the device key map + _monitorIdToHandleMap.Clear(); + foreach (var kvp in newHandleMap) + { + _monitorIdToHandleMap[kvp.Key] = kvp.Value; + } + } + } + + /// + /// Clean up handles that are no longer in use. + /// Called within lock context. Optimized to O(n) using HashSet lookup. + /// + private void CleanupUnusedHandles(Dictionary newHandles) + { + if (_monitorIdToHandleMap.IsEmpty) + { + return; + } + + // Build HashSet of handles that will be reused (O(m)) + var reusedHandles = new HashSet(newHandles.Values); + + // Find handles to destroy: in old map but not reused (O(n) with O(1) lookup) + var handlesToDestroy = _monitorIdToHandleMap.Values + .Where(h => h != IntPtr.Zero && !reusedHandles.Contains(h)) + .ToList(); + + // Destroy unused handles + foreach (var handle in handlesToDestroy) + { + try + { + DestroyPhysicalMonitor(handle); + } + catch (Exception ex) + { + Logger.LogTrace($"Failed to destroy physical monitor handle 0x{handle:X}: {ex.Message}"); + } + } + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + // Release all physical monitor handles - get snapshot to avoid holding lock during cleanup + var handles = _monitorIdToHandleMap.Values.ToList(); + foreach (var handle in handles) + { + if (handle != IntPtr.Zero) + { + try + { + DestroyPhysicalMonitor(handle); + } + catch (Exception ex) + { + Logger.LogTrace($"Failed to destroy physical monitor handle 0x{handle:X} during dispose: {ex.Message}"); + } + } + } + + _monitorIdToHandleMap.Clear(); + _disposed = true; + GC.SuppressFinalize(this); + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeConstants.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeConstants.cs new file mode 100644 index 0000000000..7a3983cc4f --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeConstants.cs @@ -0,0 +1,146 @@ +// 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. + +namespace PowerDisplay.Common.Drivers +{ + /// + /// Windows API constant definitions + /// + public static class NativeConstants + { + /// + /// VCP code: Brightness (0x10) + /// Standard VESA MCCS brightness control. + /// This is the ONLY brightness code used by PowerDisplay. + /// + public const byte VcpCodeBrightness = 0x10; + + /// + /// VCP code: Contrast (0x12) + /// Standard VESA MCCS contrast control. + /// + public const byte VcpCodeContrast = 0x12; + + /// + /// VCP code: Audio Speaker Volume (0x62) + /// Standard VESA MCCS volume control for monitors with built-in speakers. + /// + public const byte VcpCodeVolume = 0x62; + + /// + /// VCP code: Select Color Preset (0x14) + /// Standard VESA MCCS color temperature preset selection. + /// Supports discrete values like: 0x01=sRGB, 0x04=5000K, 0x05=6500K, 0x08=9300K. + /// This is the standard method for color temperature control. + /// + public const byte VcpCodeSelectColorPreset = 0x14; + + /// + /// VCP code: Input Source (0x60) + /// Standard VESA MCCS input source selection. + /// Supports values like: 0x0F=DisplayPort-1, 0x10=DisplayPort-2, 0x11=HDMI-1, 0x12=HDMI-2, 0x1B=USB-C. + /// Note: Actual supported values depend on monitor capabilities. + /// + public const byte VcpCodeInputSource = 0x60; + + /// + /// VCP code: Power Mode (0xD6) + /// Controls monitor power state via DPMS. + /// Values: 0x01=On, 0x02=Standby, 0x03=Suspend, 0x04=Off(DPM), 0x05=Off(Hard). + /// Note: Switching to any non-On state will turn off the display. + /// + public const byte VcpCodePowerMode = 0xD6; + + /// + /// Query display config: only active paths + /// + public const uint QdcOnlyActivePaths = 0x00000002; + + /// + /// Get source name (GDI device name like "\\.\DISPLAY1") + /// + public const uint DisplayconfigDeviceInfoGetSourceName = 1; + + /// + /// Get target name (monitor friendly name and hardware ID) + /// + public const uint DisplayconfigDeviceInfoGetTargetName = 2; + + /// + /// Retrieve the current settings for the display device. + /// + public const int EnumCurrentSettings = -1; + + /// + /// The display is in the natural orientation of the device. + /// + public const int DmdoDefault = 0; + + /// + /// The display is rotated 180 degrees (measured clockwise) from its natural orientation. + /// + public const int Dmdo180 = 2; + + // ==================== DEVMODE field flags ==================== + + /// + /// DmDisplayOrientation field is valid. + /// + public const int DmDisplayOrientation = 0x00000080; + + /// + /// DmPelsWidth field is valid. + /// + public const int DmPelsWidth = 0x00080000; + + /// + /// DmPelsHeight field is valid. + /// + public const int DmPelsHeight = 0x00100000; + + // ==================== ChangeDisplaySettings flags ==================== + + /// + /// Test the graphics mode but don't actually set it. + /// + public const uint CdsTest = 0x00000002; + + // ==================== ChangeDisplaySettings result codes ==================== + + /// + /// The settings change was successful. + /// + public const int DispChangeSuccessful = 0; + + /// + /// The computer must be restarted for the graphics mode to work. + /// + public const int DispChangeRestart = 1; + + /// + /// The display driver failed the specified graphics mode. + /// + public const int DispChangeFailed = -1; + + /// + /// The graphics mode is not supported. + /// + public const int DispChangeBadmode = -2; + + /// + /// Unable to write settings to the registry. + /// + public const int DispChangeNotupdated = -3; + + /// + /// An invalid set of flags was passed in. + /// + public const int DispChangeBadflags = -4; + + /// + /// An invalid parameter was passed in. + /// + public const int DispChangeBadparam = -5; + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeDelegates.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeDelegates.cs new file mode 100644 index 0000000000..03f77535d0 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeDelegates.cs @@ -0,0 +1,25 @@ +// 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.Runtime.InteropServices; + +namespace PowerDisplay.Common.Drivers; + +/// +/// Native delegate type definitions +/// +public static class NativeDelegates +{ + /// + /// Monitor enumeration procedure delegate + /// + /// Monitor handle + /// Monitor device context + /// Pointer to monitor rectangle + /// User data + /// True to continue enumeration + [UnmanagedFunctionPointer(CallingConvention.StdCall)] + public delegate bool MonitorEnumProc(IntPtr hMonitor, IntPtr hdcMonitor, IntPtr lprcMonitor, IntPtr dwData); +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DevMode.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DevMode.cs new file mode 100644 index 0000000000..7a600d5b7e --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DevMode.cs @@ -0,0 +1,55 @@ +// 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.Runtime.InteropServices; + +namespace PowerDisplay.Common.Drivers +{ + /// + /// The DEVMODE structure contains information about the initialization and environment of a printer or a display device. + /// + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public unsafe struct DevMode + { + /// + /// Device name - fixed buffer for LibraryImport compatibility + /// + public fixed ushort DmDeviceName[32]; + + public short DmSpecVersion; + public short DmDriverVersion; + public short DmSize; + public short DmDriverExtra; + public int DmFields; + public int DmPositionX; + public int DmPositionY; + public int DmDisplayOrientation; + public int DmDisplayFixedOutput; + public short DmColor; + public short DmDuplex; + public short DmYResolution; + public short DmTTOption; + public short DmCollate; + + /// + /// Form name - fixed buffer for LibraryImport compatibility + /// + public fixed ushort DmFormName[32]; + + public short DmLogPixels; + public int DmBitsPerPel; + public int DmPelsWidth; + public int DmPelsHeight; + public int DmDisplayFlags; + public int DmDisplayFrequency; + public int DmICMMethod; + public int DmICMIntent; + public int DmMediaType; + public int DmDitherType; + public int DmReserved1; + public int DmReserved2; + public int DmPanningWidth; + public int DmPanningHeight; + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfig2DRegion.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfig2DRegion.cs new file mode 100644 index 0000000000..27c7ea1c7f --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfig2DRegion.cs @@ -0,0 +1,18 @@ +// 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.Runtime.InteropServices; + +namespace PowerDisplay.Common.Drivers +{ + /// + /// Display configuration 2D region + /// + [StructLayout(LayoutKind.Sequential)] + public struct DisplayConfig2DRegion + { + public uint Cx; + public uint Cy; + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigDeviceInfoHeader.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigDeviceInfoHeader.cs new file mode 100644 index 0000000000..48b2bbcde5 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigDeviceInfoHeader.cs @@ -0,0 +1,22 @@ +// 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.Runtime.InteropServices; + +using Windows.Win32.Foundation; + +namespace PowerDisplay.Common.Drivers +{ + /// + /// Display configuration device information header + /// + [StructLayout(LayoutKind.Sequential)] + public struct DisplayConfigDeviceInfoHeader + { + public uint Type; + public uint Size; + public LUID AdapterId; + public uint Id; + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigModeInfo.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigModeInfo.cs new file mode 100644 index 0000000000..9c63467659 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigModeInfo.cs @@ -0,0 +1,22 @@ +// 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.Runtime.InteropServices; + +using Windows.Win32.Foundation; + +namespace PowerDisplay.Common.Drivers +{ + /// + /// Display configuration mode information + /// + [StructLayout(LayoutKind.Sequential)] + public struct DisplayConfigModeInfo + { + public uint InfoType; + public uint Id; + public LUID AdapterId; + public DisplayConfigModeInfoUnion ModeInfo; + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigModeInfoUnion.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigModeInfoUnion.cs new file mode 100644 index 0000000000..aabe635a41 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigModeInfoUnion.cs @@ -0,0 +1,21 @@ +// 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.Runtime.InteropServices; + +namespace PowerDisplay.Common.Drivers +{ + /// + /// Display configuration mode information union + /// + [StructLayout(LayoutKind.Explicit)] + public struct DisplayConfigModeInfoUnion + { + [FieldOffset(0)] + public DisplayConfigTargetMode TargetMode; + + [FieldOffset(0)] + public DisplayConfigSourceMode SourceMode; + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigPathInfo.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigPathInfo.cs new file mode 100644 index 0000000000..06880ec425 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigPathInfo.cs @@ -0,0 +1,19 @@ +// 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.Runtime.InteropServices; + +namespace PowerDisplay.Common.Drivers +{ + /// + /// Display configuration path information + /// + [StructLayout(LayoutKind.Sequential)] + public struct DisplayConfigPathInfo + { + public DisplayConfigPathSourceInfo SourceInfo; + public DisplayConfigPathTargetInfo TargetInfo; + public uint Flags; + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigPathSourceInfo.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigPathSourceInfo.cs new file mode 100644 index 0000000000..ea38f3fade --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigPathSourceInfo.cs @@ -0,0 +1,22 @@ +// 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.Runtime.InteropServices; + +using Windows.Win32.Foundation; + +namespace PowerDisplay.Common.Drivers +{ + /// + /// Display configuration path source information + /// + [StructLayout(LayoutKind.Sequential)] + public struct DisplayConfigPathSourceInfo + { + public LUID AdapterId; + public uint Id; + public uint ModeInfoIdx; + public uint StatusFlags; + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigPathTargetInfo.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigPathTargetInfo.cs new file mode 100644 index 0000000000..739aef3357 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigPathTargetInfo.cs @@ -0,0 +1,28 @@ +// 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.Runtime.InteropServices; + +using Windows.Win32.Foundation; + +namespace PowerDisplay.Common.Drivers +{ + /// + /// Display configuration path target information + /// + [StructLayout(LayoutKind.Sequential)] + public struct DisplayConfigPathTargetInfo + { + public LUID AdapterId; + public uint Id; + public uint ModeInfoIdx; + public uint OutputTechnology; + public uint Rotation; + public uint Scaling; + public DisplayConfigRational RefreshRate; + public uint ScanLineOrdering; + public bool TargetAvailable; + public uint StatusFlags; + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigPoint.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigPoint.cs new file mode 100644 index 0000000000..d2ad0a76f8 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigPoint.cs @@ -0,0 +1,18 @@ +// 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.Runtime.InteropServices; + +namespace PowerDisplay.Common.Drivers +{ + /// + /// Display configuration point + /// + [StructLayout(LayoutKind.Sequential)] + public struct DisplayConfigPoint + { + public int X; + public int Y; + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigRational.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigRational.cs new file mode 100644 index 0000000000..dde4497d73 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigRational.cs @@ -0,0 +1,18 @@ +// 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.Runtime.InteropServices; + +namespace PowerDisplay.Common.Drivers +{ + /// + /// Display configuration rational number + /// + [StructLayout(LayoutKind.Sequential)] + public struct DisplayConfigRational + { + public uint Numerator; + public uint Denominator; + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigSourceDeviceName.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigSourceDeviceName.cs new file mode 100644 index 0000000000..7af54f0609 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigSourceDeviceName.cs @@ -0,0 +1,33 @@ +// 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.Runtime.InteropServices; + +namespace PowerDisplay.Common.Drivers +{ + /// + /// Display configuration source device name - contains GDI device name (e.g., "\\.\DISPLAY1") + /// + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public unsafe struct DisplayConfigSourceDeviceName + { + public DisplayConfigDeviceInfoHeader Header; + + /// + /// GDI device name - fixed buffer for 32 wide characters (CCHDEVICENAME) + /// + public fixed ushort ViewGdiDeviceName[32]; + + /// + /// Helper method to get GDI device name as string + /// + public readonly string GetViewGdiDeviceName() + { + fixed (ushort* ptr = ViewGdiDeviceName) + { + return new string((char*)ptr); + } + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigSourceMode.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigSourceMode.cs new file mode 100644 index 0000000000..a39b7a298d --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigSourceMode.cs @@ -0,0 +1,20 @@ +// 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.Runtime.InteropServices; + +namespace PowerDisplay.Common.Drivers +{ + /// + /// Display configuration source mode + /// + [StructLayout(LayoutKind.Sequential)] + public struct DisplayConfigSourceMode + { + public uint Width; + public uint Height; + public uint PixelFormat; + public DisplayConfigPoint Position; + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigTargetDeviceName.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigTargetDeviceName.cs new file mode 100644 index 0000000000..9a38f82c30 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigTargetDeviceName.cs @@ -0,0 +1,54 @@ +// 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.Runtime.InteropServices; + +namespace PowerDisplay.Common.Drivers +{ + /// + /// Display configuration target device name + /// + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public unsafe struct DisplayConfigTargetDeviceName + { + public DisplayConfigDeviceInfoHeader Header; + public uint Flags; + public uint OutputTechnology; + public ushort EdidManufactureId; + public ushort EdidProductCodeId; + public uint ConnectorInstance; + + /// + /// Monitor friendly name - fixed buffer for LibraryImport compatibility + /// + public fixed ushort MonitorFriendlyDeviceName[64]; + + /// + /// Monitor device path - fixed buffer for LibraryImport compatibility + /// + public fixed ushort MonitorDevicePath[128]; + + /// + /// Helper method to get monitor friendly name as string + /// + public readonly string GetMonitorFriendlyDeviceName() + { + fixed (ushort* ptr = MonitorFriendlyDeviceName) + { + return new string((char*)ptr); + } + } + + /// + /// Helper method to get monitor device path as string + /// + public readonly string GetMonitorDevicePath() + { + fixed (ushort* ptr = MonitorDevicePath) + { + return new string((char*)ptr); + } + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigTargetMode.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigTargetMode.cs new file mode 100644 index 0000000000..9ea0f15867 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigTargetMode.cs @@ -0,0 +1,17 @@ +// 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.Runtime.InteropServices; + +namespace PowerDisplay.Common.Drivers +{ + /// + /// Display configuration target mode + /// + [StructLayout(LayoutKind.Sequential)] + public struct DisplayConfigTargetMode + { + public DisplayConfigVideoSignalInfo TargetVideoSignalInfo; + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigVideoSignalInfo.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigVideoSignalInfo.cs new file mode 100644 index 0000000000..36c4907e5c --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigVideoSignalInfo.cs @@ -0,0 +1,23 @@ +// 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.Runtime.InteropServices; + +namespace PowerDisplay.Common.Drivers +{ + /// + /// Display configuration video signal information + /// + [StructLayout(LayoutKind.Sequential)] + public struct DisplayConfigVideoSignalInfo + { + public ulong PixelRate; + public DisplayConfigRational HSyncFreq; + public DisplayConfigRational VSyncFreq; + public DisplayConfig2DRegion ActiveSize; + public DisplayConfig2DRegion TotalSize; + public uint VideoStandard; + public uint ScanLineOrdering; + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/MonitorInfoEx.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/MonitorInfoEx.cs new file mode 100644 index 0000000000..1af97ed764 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/MonitorInfoEx.cs @@ -0,0 +1,51 @@ +// 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.Runtime.InteropServices; + +namespace PowerDisplay.Common.Drivers +{ + /// + /// Monitor information extended structure + /// + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public unsafe struct MonitorInfoEx + { + /// + /// Structure size + /// + public uint CbSize; + + /// + /// Monitor rectangle area + /// + public Rect RcMonitor; + + /// + /// Work area rectangle + /// + public Rect RcWork; + + /// + /// Flags + /// + public uint DwFlags; + + /// + /// Device name (e.g., "\\.\DISPLAY1") - fixed buffer for LibraryImport compatibility + /// + public fixed ushort SzDevice[32]; + + /// + /// Helper property to get device name as string + /// + public readonly string GetDeviceName() + { + fixed (ushort* ptr = SzDevice) + { + return new string((char*)ptr); + } + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/PhysicalMonitor.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/PhysicalMonitor.cs new file mode 100644 index 0000000000..a418dcc168 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/PhysicalMonitor.cs @@ -0,0 +1,37 @@ +// 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.Runtime.InteropServices; + +namespace PowerDisplay.Common.Drivers +{ + /// + /// Physical monitor structure for DDC/CI + /// + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public unsafe struct PhysicalMonitor + { + /// + /// Physical monitor handle + /// + public IntPtr HPhysicalMonitor; + + /// + /// Physical monitor description string - fixed buffer for LibraryImport compatibility + /// + public fixed ushort SzPhysicalMonitorDescription[128]; + + /// + /// Helper method to get description as string + /// + public readonly string GetDescription() + { + fixed (ushort* ptr = SzPhysicalMonitorDescription) + { + return new string((char*)ptr); + } + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/Rect.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/Rect.cs new file mode 100644 index 0000000000..0af4d13dc5 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/Rect.cs @@ -0,0 +1,32 @@ +// 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.Runtime.InteropServices; + +namespace PowerDisplay.Common.Drivers +{ + /// + /// Rectangle structure + /// + [StructLayout(LayoutKind.Sequential)] + public struct Rect + { + public int Left; + public int Top; + public int Right; + public int Bottom; + + public int Width => Right - Left; + + public int Height => Bottom - Top; + + public Rect(int left, int top, int right, int bottom) + { + Left = left; + Top = top; + Right = right; + Bottom = bottom; + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/PInvoke.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/PInvoke.cs new file mode 100644 index 0000000000..1e1ab5185e --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/PInvoke.cs @@ -0,0 +1,122 @@ +// 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.Runtime.InteropServices; + +namespace PowerDisplay.Common.Drivers +{ + /// + /// P/Invoke declarations using LibraryImport source generator + /// + internal static partial class PInvoke + { + // ==================== User32.dll - Display Configuration ==================== + [LibraryImport("user32.dll")] + internal static partial int GetDisplayConfigBufferSizes( + uint flags, + out uint numPathArrayElements, + out uint numModeInfoArrayElements); + + // Use unsafe pointer to avoid runtime marshalling + [LibraryImport("user32.dll")] + internal static unsafe partial int QueryDisplayConfig( + uint flags, + ref uint numPathArrayElements, + DisplayConfigPathInfo* pathArray, + ref uint numModeInfoArrayElements, + DisplayConfigModeInfo* modeInfoArray, + IntPtr currentTopologyId); + + [LibraryImport("user32.dll")] + internal static unsafe partial int DisplayConfigGetDeviceInfo( + DisplayConfigTargetDeviceName* deviceName); + + [LibraryImport("user32.dll")] + internal static unsafe partial int DisplayConfigGetDeviceInfo( + DisplayConfigSourceDeviceName* sourceName); + + // ==================== User32.dll - Monitor Enumeration ==================== + [LibraryImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool EnumDisplayMonitors( + IntPtr hdc, + IntPtr lprcClip, + NativeDelegates.MonitorEnumProc lpfnEnum, + IntPtr dwData); + + [LibraryImport("user32.dll", EntryPoint = "GetMonitorInfoW", StringMarshalling = StringMarshalling.Utf16)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static unsafe partial bool GetMonitorInfo( + IntPtr hMonitor, + MonitorInfoEx* lpmi); + + [LibraryImport("user32.dll", EntryPoint = "EnumDisplaySettingsW", StringMarshalling = StringMarshalling.Utf16)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static unsafe partial bool EnumDisplaySettings( + [MarshalAs(UnmanagedType.LPWStr)] string? lpszDeviceName, + 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); + + // ==================== Dxva2.dll - DDC/CI Monitor Control ==================== + [LibraryImport("Dxva2.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool GetNumberOfPhysicalMonitorsFromHMONITOR( + IntPtr hMonitor, + out uint pdwNumberOfPhysicalMonitors); + + // Use unsafe pointer to avoid ArraySubType limitation + [LibraryImport("Dxva2.dll", StringMarshalling = StringMarshalling.Utf16)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static unsafe partial bool GetPhysicalMonitorsFromHMONITOR( + IntPtr hMonitor, + uint dwPhysicalMonitorArraySize, + PhysicalMonitor* pPhysicalMonitorArray); + + [LibraryImport("Dxva2.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool DestroyPhysicalMonitor(IntPtr hMonitor); + + [LibraryImport("Dxva2.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool GetVCPFeatureAndVCPFeatureReply( + IntPtr hPhysicalMonitor, + byte bVCPCode, + IntPtr pvct, + out uint pdwCurrentValue, + out uint pdwMaximumValue); + + [LibraryImport("Dxva2.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool SetVCPFeature( + IntPtr hPhysicalMonitor, + byte bVCPCode, + uint dwNewValue); + + [LibraryImport("Dxva2.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool GetCapabilitiesStringLength( + IntPtr hPhysicalMonitor, + out uint pdwCapabilitiesStringLengthInCharacters); + + [LibraryImport("Dxva2.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool CapabilitiesRequestAndCapabilitiesReply( + IntPtr hPhysicalMonitor, + IntPtr pszASCIICapabilitiesString, + uint dwCapabilitiesStringLengthInCharacters); + + // ==================== Kernel32.dll ==================== + [LibraryImport("kernel32.dll")] + internal static partial uint GetLastError(); + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/WMI/WmiController.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/WMI/WmiController.cs new file mode 100644 index 0000000000..4b464500da --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/WMI/WmiController.cs @@ -0,0 +1,387 @@ +// 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.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ManagedCommon; +using PowerDisplay.Common.Interfaces; +using PowerDisplay.Common.Models; +using PowerDisplay.Common.Utils; +using WmiLight; +using Monitor = PowerDisplay.Common.Models.Monitor; + +namespace PowerDisplay.Common.Drivers.WMI +{ + /// + /// WMI monitor controller for controlling internal laptop displays. + /// + public partial class WmiController : IMonitorController, IDisposable + { + private const string WmiNamespace = @"root\WMI"; + private const string BrightnessQueryClass = "WmiMonitorBrightness"; + private const string BrightnessMethodClass = "WmiMonitorBrightnessMethods"; + + // 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; + + /// + /// Classifies WMI exceptions into user-friendly error messages. + /// + 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), + }; + } + + /// + /// Escape special characters in WMI query strings. + /// WMI requires backslashes and single quotes to be escaped in WHERE clauses. + /// See: https://learn.microsoft.com/en-us/windows/win32/wmisdk/wql-sql-for-wmi + /// + /// The string value to escape. + /// The escaped string safe for use in WMI queries. + private static string EscapeWmiString(string value) + { + if (string.IsNullOrEmpty(value)) + { + return value; + } + + // WMI requires backslashes and single quotes to be escaped in WHERE clauses + // Backslash must be escaped first to avoid double-escaping the quote's backslash + return value.Replace("\\", "\\\\").Replace("'", "\\'"); + } + + /// + /// Extract hardware ID from WMI InstanceName. + /// InstanceName format: "DISPLAY\BOE0900\4&10fd3ab1&0&UID265988_0" + /// Returns the second segment (e.g., "BOE0900") which is the manufacturer+product code. + /// + /// The WMI InstanceName. + /// The EDID ID extracted from the InstanceName, or empty string if extraction fails. + private static string ExtractEdidIdFromInstanceName(string instanceName) + { + if (string.IsNullOrEmpty(instanceName)) + { + return string.Empty; + } + + // Split by backslash: ["DISPLAY", "BOE0900", "4&10fd3ab1&0&UID265988_0"] + var parts = instanceName.Split('\\'); + if (parts.Length >= 2 && !string.IsNullOrEmpty(parts[1])) + { + // Return the second part (e.g., "BOE0900") + return parts[1]; + } + + return string.Empty; + } + + /// + /// Build a WMI query filtered by monitor instance name. + /// + /// The WMI class to query. + /// The monitor instance name to filter by. + /// Optional SELECT clause fields (defaults to "*"). + /// The formatted WMI query string. + private static string BuildInstanceNameQuery(string wmiClass, string instanceName, string selectClause = "*") + { + var escapedInstanceName = EscapeWmiString(instanceName); + return $"SELECT {selectClause} FROM {wmiClass} WHERE InstanceName = '{escapedInstanceName}'"; + } + + /// + /// Get MonitorDisplayInfo from dictionary by matching EdidId. + /// Uses QueryDisplayConfig path index which matches Windows Display Settings "Identify" feature. + /// + /// The EDID ID to match (e.g., "LEN4038", "BOE0900"). + /// Dictionary of monitor display info from QueryDisplayConfig. + /// MonitorDisplayInfo if found, or null if not found. + private static Drivers.DDC.MonitorDisplayInfo? GetMonitorDisplayInfoByEdidId(string edidId, Dictionary monitorDisplayInfos) + { + if (string.IsNullOrEmpty(edidId) || monitorDisplayInfos == null || monitorDisplayInfos.Count == 0) + { + return null; + } + + var match = monitorDisplayInfos.Values.FirstOrDefault( + v => edidId.Equals(v.EdidId, StringComparison.OrdinalIgnoreCase)); + + // Check if match was found (struct default has null/empty EdidId) + if (!string.IsNullOrEmpty(match.EdidId)) + { + return match; + } + + Logger.LogWarning($"WMI: Could not find MonitorDisplayInfo for EdidId '{edidId}'"); + return null; + } + + public string Name => "WMI Monitor Controller"; + + /// + /// Get monitor brightness + /// + public async Task GetBrightnessAsync(Monitor monitor, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(monitor); + + return await Task.Run( + () => + { + try + { + using var connection = new WmiConnection(WmiNamespace); + var query = BuildInstanceNameQuery(BrightnessQueryClass, monitor.InstanceName, "CurrentBrightness"); + var results = connection.CreateQuery(query); + + foreach (var obj in results) + { + var currentBrightness = obj.GetPropertyValue("CurrentBrightness"); + return new VcpFeatureValue(currentBrightness, 0, 100); + } + + // No match found - monitor may have been disconnected + } + catch (WmiException ex) + { + Logger.LogWarning($"WMI GetBrightness failed: {ex.Message} (HResult: 0x{ex.HResult:X})"); + } + catch (Exception ex) + { + Logger.LogWarning($"WMI GetBrightness failed: {ex.Message}"); + } + + return VcpFeatureValue.Invalid; + }, + cancellationToken); + } + + /// + /// Set monitor brightness + /// + public async Task SetBrightnessAsync(Monitor monitor, int brightness, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(monitor); + + // Validate brightness range + brightness = Math.Clamp(brightness, 0, 100); + + return await Task.Run( + () => + { + try + { + using var connection = new WmiConnection(WmiNamespace); + var query = BuildInstanceNameQuery(BrightnessMethodClass, monitor.InstanceName); + var results = connection.CreateQuery(query); + + foreach (var obj in results) + { + // Call WmiSetBrightness method + // Parameters: Timeout (uint32), Brightness (uint8) + // Note: WmiLight requires string values for method parameters + using (WmiMethod method = obj.GetMethod("WmiSetBrightness")) + using (WmiMethodParameters inParams = method.CreateInParameters()) + { + inParams.SetPropertyValue("Timeout", "0"); + inParams.SetPropertyValue("Brightness", brightness.ToString(CultureInfo.InvariantCulture)); + + uint result = obj.ExecuteMethod( + method, + inParams, + out WmiMethodParameters outParams); + + // Check return value (0 indicates success) + if (result == 0) + { + return MonitorOperationResult.Success(); + } + + return MonitorOperationResult.Failure($"WMI method returned error code: {result}", (int)result); + } + } + + // No match found - monitor may have been disconnected + Logger.LogWarning($"WMI SetBrightness: No monitor found with InstanceName '{monitor.InstanceName}'"); + return MonitorOperationResult.Failure($"No WMI brightness method found for monitor '{monitor.InstanceName}'"); + } + catch (UnauthorizedAccessException) + { + return MonitorOperationResult.Failure("Access denied. Administrator privileges may be required.", 5); + } + catch (WmiException ex) + { + return ClassifyWmiError(ex, "SetBrightness"); + } + catch (Exception ex) + { + return MonitorOperationResult.Failure($"Unexpected error during SetBrightness: {ex.Message}"); + } + }, + cancellationToken); + } + + /// + /// Discover supported monitors. + /// WMI brightness control is typically only available on internal laptop displays, + /// which don't have meaningful UserFriendlyName in WmiMonitorID, so we use "Built-in Display". + /// + public async Task> DiscoverMonitorsAsync(CancellationToken cancellationToken = default) + { + return await Task.Run( + () => + { + var monitors = new List(); + + try + { + using var connection = new WmiConnection(WmiNamespace); + + // Query WMI brightness support - only internal displays typically support this + var brightnessQuery = $"SELECT InstanceName, CurrentBrightness FROM {BrightnessQueryClass}"; + var brightnessResults = connection.CreateQuery(brightnessQuery).ToList(); + + if (brightnessResults.Count == 0) + { + return monitors; + } + + // Get MonitorDisplayInfo from QueryDisplayConfig - this provides the correct monitor numbers + var monitorDisplayInfos = Drivers.DDC.DdcCiNative.GetAllMonitorDisplayInfo(); + + // Create monitor objects for each supported brightness instance + foreach (var obj in brightnessResults) + { + try + { + var instanceName = obj.GetPropertyValue("InstanceName") ?? string.Empty; + var currentBrightness = obj.GetPropertyValue("CurrentBrightness"); + + // Extract EDID ID from InstanceName + // e.g., "DISPLAY\LEN4038\4&40f4dee&0&UID8388688_0" -> "LEN4038" + var edidId = ExtractEdidIdFromInstanceName(instanceName); + + // Get MonitorDisplayInfo from QueryDisplayConfig by matching EDID ID + // This provides MonitorNumber and GdiDeviceName for display settings APIs + var displayInfo = GetMonitorDisplayInfoByEdidId(edidId, monitorDisplayInfos); + int monitorNumber = displayInfo?.MonitorNumber ?? 0; + string gdiDeviceName = displayInfo?.GdiDeviceName ?? string.Empty; + + // Generate unique ID: "WMI_{EdidId}_{MonitorNumber}" + string uniqueId = !string.IsNullOrEmpty(edidId) + ? $"WMI_{edidId}_{monitorNumber}" + : $"WMI_Unknown_{monitorNumber}"; + + // Get display name from PnP manufacturer ID (e.g., "Lenovo Built-in Display") + var displayName = PnpIdHelper.GetBuiltInDisplayName(edidId); + + var monitor = new Monitor + { + Id = uniqueId, + Name = displayName, + CurrentBrightness = currentBrightness, + MinBrightness = 0, + MaxBrightness = 100, + IsAvailable = true, + InstanceName = instanceName, + Capabilities = MonitorCapabilities.Brightness | MonitorCapabilities.Wmi, + CommunicationMethod = "WMI", + SupportsColorTemperature = false, + MonitorNumber = monitorNumber, + GdiDeviceName = gdiDeviceName, + }; + + monitors.Add(monitor); + } + catch (Exception ex) + { + Logger.LogWarning($"Failed to create monitor from WMI data: {ex.Message}"); + } + } + } + catch (WmiException ex) + { + Logger.LogError($"WMI DiscoverMonitors failed: {ex.Message} (HResult: 0x{ex.HResult:X})"); + } + catch (Exception ex) + { + Logger.LogError($"WMI DiscoverMonitors failed: {ex.Message}"); + } + + return monitors; + }, + cancellationToken); + } + + // Extended features not supported by WMI + public Task SetContrastAsync(Monitor monitor, int contrast, CancellationToken cancellationToken = default) + { + return Task.FromResult(MonitorOperationResult.Failure("Contrast control not supported via WMI")); + } + + public Task SetVolumeAsync(Monitor monitor, int volume, CancellationToken cancellationToken = default) + { + return Task.FromResult(MonitorOperationResult.Failure("Volume control not supported via WMI")); + } + + public Task GetColorTemperatureAsync(Monitor monitor, CancellationToken cancellationToken = default) + { + return Task.FromResult(VcpFeatureValue.Invalid); + } + + public Task SetColorTemperatureAsync(Monitor monitor, int colorTemperature, CancellationToken cancellationToken = default) + { + return Task.FromResult(MonitorOperationResult.Failure("Color temperature control not supported via WMI")); + } + + public Task GetInputSourceAsync(Monitor monitor, CancellationToken cancellationToken = default) + { + // Input source switching not supported for internal displays + return Task.FromResult(VcpFeatureValue.Invalid); + } + + public Task SetInputSourceAsync(Monitor monitor, int inputSource, CancellationToken cancellationToken = default) + { + // Input source switching not supported for internal displays + return Task.FromResult(MonitorOperationResult.Failure("Input source switching not supported via WMI")); + } + + public Task SetPowerStateAsync(Monitor monitor, int powerState, CancellationToken cancellationToken = default) + { + // Power state control not supported for internal displays via WMI + return Task.FromResult(MonitorOperationResult.Failure("Power state control not supported via WMI")); + } + + public Task GetPowerStateAsync(Monitor monitor, CancellationToken cancellationToken = default) + { + // Power state control not supported for internal displays via WMI + return Task.FromResult(VcpFeatureValue.Invalid); + } + + public void Dispose() + { + // WmiLight objects are created per-operation and disposed immediately via using statements. + // No instance-level resources require cleanup. + GC.SuppressFinalize(this); + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Interfaces/IMonitorController.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Interfaces/IMonitorController.cs new file mode 100644 index 0000000000..cd3a6fe15d --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Interfaces/IMonitorController.cs @@ -0,0 +1,122 @@ +// 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.Threading; +using System.Threading.Tasks; +using PowerDisplay.Common.Models; +using Monitor = PowerDisplay.Common.Models.Monitor; + +namespace PowerDisplay.Common.Interfaces +{ + /// + /// Monitor controller interface + /// + public interface IMonitorController + { + /// + /// Gets controller name + /// + string Name { get; } + + /// + /// Gets monitor brightness + /// + /// Monitor object + /// Cancellation token + /// Brightness information + Task GetBrightnessAsync(Monitor monitor, CancellationToken cancellationToken = default); + + /// + /// Sets monitor brightness + /// + /// Monitor object + /// Brightness value (0-100) + /// Cancellation token + /// Operation result + Task SetBrightnessAsync(Monitor monitor, int brightness, CancellationToken cancellationToken = default); + + /// + /// Discovers supported monitors + /// + /// Cancellation token + /// List of monitors + Task> DiscoverMonitorsAsync(CancellationToken cancellationToken = default); + + /// + /// Sets monitor contrast + /// + /// Monitor object + /// Contrast value (0-100) + /// Cancellation token + /// Operation result + Task SetContrastAsync(Monitor monitor, int contrast, CancellationToken cancellationToken = default); + + /// + /// Sets monitor volume + /// + /// Monitor object + /// Volume value (0-100) + /// Cancellation token + /// Operation result + Task SetVolumeAsync(Monitor monitor, int volume, CancellationToken cancellationToken = default); + + /// + /// Gets monitor color temperature using VCP 0x14 (Select Color Preset) + /// + /// Monitor object + /// Cancellation token + /// VCP preset value (e.g., 0x05 for 6500K), not Kelvin temperature + Task GetColorTemperatureAsync(Monitor monitor, CancellationToken cancellationToken = default); + + /// + /// Sets monitor color temperature using VCP 0x14 preset value + /// + /// Monitor object + /// VCP preset value (e.g., 0x05 for 6500K), not Kelvin temperature + /// Cancellation token + /// Operation result + Task SetColorTemperatureAsync(Monitor monitor, int colorTemperature, CancellationToken cancellationToken = default); + + /// + /// Gets current input source using VCP 0x60 + /// + /// Monitor object + /// Cancellation token + /// VCP input source value (e.g., 0x11 for HDMI-1) + Task GetInputSourceAsync(Monitor monitor, CancellationToken cancellationToken = default); + + /// + /// Sets input source using VCP 0x60 + /// + /// Monitor object + /// VCP input source value (e.g., 0x11 for HDMI-1) + /// Cancellation token + /// Operation result + Task SetInputSourceAsync(Monitor monitor, int inputSource, CancellationToken cancellationToken = default); + + /// + /// Sets power state using VCP 0xD6 (Power Mode) + /// + /// Monitor object + /// VCP power state value: 0x01=On, 0x02=Standby, 0x03=Suspend, 0x04=Off(DPM), 0x05=Off(Hard) + /// Cancellation token + /// Operation result + Task SetPowerStateAsync(Monitor monitor, int powerState, CancellationToken cancellationToken = default); + + /// + /// Gets current power state using VCP 0xD6 (Power Mode) + /// + /// Monitor object + /// Cancellation token + /// VCP power state value: 0x01=On, 0x02=Standby, 0x03=Suspend, 0x04=Off(DPM), 0x05=Off(Hard) + Task GetPowerStateAsync(Monitor monitor, CancellationToken cancellationToken = default); + + /// + /// Releases resources + /// + void Dispose(); + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Interfaces/IMonitorData.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Interfaces/IMonitorData.cs new file mode 100644 index 0000000000..26f156b97c --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Interfaces/IMonitorData.cs @@ -0,0 +1,56 @@ +// 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. + +namespace PowerDisplay.Common.Interfaces +{ + /// + /// Core interface representing monitor hardware data. + /// This interface defines the actual hardware values for a monitor. + /// Implementations can add UI-specific properties and use converters for display formatting. + /// + public interface IMonitorData + { + /// + /// Gets or sets the unique identifier for the monitor. + /// + string Id { get; set; } + + /// + /// Gets or sets the display name of the monitor. + /// + string Name { get; set; } + + /// + /// Gets or sets the current brightness value (0-100). + /// + int Brightness { get; set; } + + /// + /// Gets or sets the current contrast value (0-100). + /// + int Contrast { get; set; } + + /// + /// Gets or sets the current volume value (0-100). + /// + int Volume { get; set; } + + /// + /// Gets or sets the color temperature VCP preset value (raw DDC/CI value from VCP code 0x14). + /// This stores the raw VCP value (e.g., 0x05 for 6500K preset), not the Kelvin temperature. + /// Use ColorTemperatureHelper to convert to/from human-readable display names. + /// + int ColorTemperatureVcp { get; set; } + + /// + /// Gets or sets the monitor number (1, 2, 3...) as assigned by the OS. + /// + int MonitorNumber { get; set; } + + /// + /// Gets or sets the monitor orientation (0=0, 1=90, 2=180, 3=270). + /// + int Orientation { get; set; } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Interfaces/IProfileService.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Interfaces/IProfileService.cs new file mode 100644 index 0000000000..3562346b94 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Interfaces/IProfileService.cs @@ -0,0 +1,62 @@ +// 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 PowerDisplay.Common.Models; + +namespace PowerDisplay.Common.Interfaces +{ + /// + /// Interface for profile management service. + /// Provides abstraction for loading, saving, and managing PowerDisplay profiles. + /// Enables dependency injection and unit testing. + /// + public interface IProfileService + { + /// + /// Loads PowerDisplay profiles from disk. + /// + /// PowerDisplayProfiles object, or a new empty instance if file doesn't exist or load fails. + PowerDisplayProfiles LoadProfiles(); + + /// + /// Saves PowerDisplay profiles to disk. + /// + /// The profiles collection to save. + /// True if save was successful, false otherwise. + bool SaveProfiles(PowerDisplayProfiles profiles); + + /// + /// Adds or updates a profile in the collection and persists to disk. + /// + /// The profile to add or update. + /// True if operation was successful, false otherwise. + bool AddOrUpdateProfile(PowerDisplayProfile profile); + + /// + /// Removes a profile by name and persists to disk. + /// + /// The name of the profile to remove. + /// True if profile was found and removed, false otherwise. + bool RemoveProfile(string profileName); + + /// + /// Gets a profile by name. + /// + /// The name of the profile to retrieve. + /// The profile if found, null otherwise. + PowerDisplayProfile? GetProfile(string profileName); + + /// + /// Checks if the profiles file exists. + /// + /// True if profiles file exists, false otherwise. + bool ProfilesFileExists(); + + /// + /// Gets the path to the profiles file. + /// + /// The full path to the profiles file. + string GetProfilesFilePath(); + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Models/ColorPresetItem.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Models/ColorPresetItem.cs new file mode 100644 index 0000000000..13baa45ed2 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Models/ColorPresetItem.cs @@ -0,0 +1,86 @@ +// 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.ComponentModel; +using System.Runtime.CompilerServices; +using System.Text.Json.Serialization; + +namespace PowerDisplay.Common.Models +{ + /// + /// Represents a color temperature preset item for VCP code 0x14. + /// Used to display available color temperature presets in UI components. + /// + public partial class ColorPresetItem : INotifyPropertyChanged + { + private int _vcpValue; + private string _displayName = string.Empty; + + /// + /// Initializes a new instance of the class. + /// + public ColorPresetItem() + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The VCP value for the color temperature preset. + /// The display name for UI. + public ColorPresetItem(int vcpValue, string displayName) + { + _vcpValue = vcpValue; + _displayName = displayName; + } + + /// + /// Occurs when a property value changes. + /// + public event PropertyChangedEventHandler? PropertyChanged; + + /// + /// Gets or sets the VCP value for this color temperature preset. + /// + [JsonPropertyName("vcpValue")] + public int VcpValue + { + get => _vcpValue; + set + { + if (_vcpValue != value) + { + _vcpValue = value; + OnPropertyChanged(); + } + } + } + + /// + /// Gets or sets the display name for UI. + /// + [JsonPropertyName("displayName")] + public string DisplayName + { + get => _displayName; + set + { + if (_displayName != value) + { + _displayName = value; + OnPropertyChanged(); + } + } + } + + /// + /// Raises the PropertyChanged event. + /// + /// The name of the property that changed. + protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Models/Monitor.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Models/Monitor.cs new file mode 100644 index 0000000000..2b72890803 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Models/Monitor.cs @@ -0,0 +1,382 @@ +// 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.ComponentModel; +using System.Runtime.CompilerServices; +using PowerDisplay.Common.Interfaces; +using PowerDisplay.Common.Utils; + +namespace PowerDisplay.Common.Models +{ + /// + /// Monitor model that implements property change notification. + /// Implements IMonitorData to provide a common interface for monitor hardware values. + /// + /// + /// is the unique identifier used for all purposes: UI lookups, IPC, persistent storage, and handle management. + /// Format: "{Source}_{EdidId}_{MonitorNumber}" (e.g., "DDC_GSM5C6D_1", "WMI_BOE0900_2"). + /// + public partial class Monitor : INotifyPropertyChanged, IMonitorData + { + private int _currentBrightness; + private int _currentColorTemperature = 0x05; // Default to 6500K preset (VCP 0x14 value) + private int _currentInputSource; // VCP 0x60 value + private int _currentPowerState = 0x01; // Default to On (VCP 0xD6 value) + private bool _isAvailable = true; + private int _orientation; + + /// + /// Gets or sets unique identifier for all purposes: UI lookups, IPC, persistent storage, and handle management. + /// + /// + /// Format: "{Source}_{EdidId}_{MonitorNumber}" where Source is "DDC" or "WMI". + /// Examples: "DDC_GSM5C6D_1", "WMI_BOE0900_2". + /// Stable across reboots and unique even for multiple identical monitors. + /// + public string Id { get; set; } = string.Empty; + + /// + /// Gets or sets display name + /// + public string Name { get; set; } = string.Empty; + + /// + /// Gets or sets current brightness (0-100) + /// + public int CurrentBrightness + { + get => _currentBrightness; + set + { + var clamped = Math.Clamp(value, MinBrightness, MaxBrightness); + if (_currentBrightness != clamped) + { + _currentBrightness = clamped; + OnPropertyChanged(); + } + } + } + + /// + /// Gets or sets minimum brightness value + /// + public int MinBrightness { get; set; } + + /// + /// Gets or sets maximum brightness value + /// + public int MaxBrightness { get; set; } = 100; + + /// + /// Gets or sets current color temperature VCP preset value (from VCP code 0x14). + /// This stores the raw VCP value (e.g., 0x05 for 6500K), not Kelvin temperature. + /// Use ColorTemperaturePresetName to get human-readable name. + /// + public int CurrentColorTemperature + { + get => _currentColorTemperature; + set + { + if (_currentColorTemperature != value) + { + _currentColorTemperature = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(ColorTemperaturePresetName)); + } + } + } + + /// + /// Gets human-readable color temperature preset name (e.g., "6500K (0x05)", "sRGB (0x01)") + /// + public string ColorTemperaturePresetName => + VcpNames.GetFormattedValueName(0x14, CurrentColorTemperature); + + /// + /// Gets or sets a value indicating whether the monitor supports color temperature adjustment via VCP 0x14 + /// + public bool SupportsColorTemperature { get; set; } + + /// + /// Gets or sets current input source VCP value (from VCP code 0x60). + /// This stores the raw VCP value (e.g., 0x11 for HDMI-1). + /// Use InputSourceName to get human-readable name. + /// + public int CurrentInputSource + { + get => _currentInputSource; + set + { + if (_currentInputSource != value) + { + _currentInputSource = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(InputSourceName)); + } + } + } + + /// + /// Gets human-readable input source name (e.g., "HDMI-1", "DisplayPort-1") + /// Returns just the name without hex value for cleaner UI display. + /// + public string InputSourceName => + VcpNames.GetValueName(0x60, CurrentInputSource) ?? $"Source 0x{CurrentInputSource:X2}"; + + /// + /// Gets a value indicating whether the monitor supports input source switching via VCP 0x60 + /// + public bool SupportsInputSource => VcpCapabilitiesInfo?.SupportsVcpCode(0x60) ?? false; + + /// + /// Gets get supported input sources from capabilities (as list of VCP values) + /// + public System.Collections.Generic.IReadOnlyList? SupportedInputSources => + VcpCapabilitiesInfo?.GetSupportedValues(0x60); + + /// + /// Gets a value indicating whether the monitor supports power state control via VCP 0xD6 + /// + public bool SupportsPowerState => VcpCapabilitiesInfo?.SupportsVcpCode(0xD6) ?? false; + + /// + /// Gets supported power states from capabilities (as list of VCP values) + /// Values: 0x01=On, 0x02=Standby, 0x03=Suspend, 0x04=Off(DPM), 0x05=Off(Hard) + /// + public System.Collections.Generic.IReadOnlyList? SupportedPowerStates => + VcpCapabilitiesInfo?.GetSupportedValues(0xD6); + + /// + /// Gets or sets current power state VCP value (from VCP code 0xD6). + /// Values: 0x01=On, 0x02=Standby, 0x03=Suspend, 0x04=Off(DPM), 0x05=Off(Hard). + /// + public int CurrentPowerState + { + get => _currentPowerState; + set + { + if (_currentPowerState != value) + { + _currentPowerState = value; + OnPropertyChanged(); + } + } + } + + /// + /// Gets a value indicating whether the monitor supports contrast adjustment + /// + public bool SupportsContrast => Capabilities.HasFlag(MonitorCapabilities.Contrast); + + /// + /// Gets a value indicating whether the monitor supports volume adjustment (for audio-capable monitors) + /// + public bool SupportsVolume => Capabilities.HasFlag(MonitorCapabilities.Volume); + + private int _currentContrast = 50; + private int _currentVolume = 50; + + /// + /// Gets or sets current contrast (0-100) + /// + public int CurrentContrast + { + get => _currentContrast; + set + { + var clamped = Math.Clamp(value, MinContrast, MaxContrast); + if (_currentContrast != clamped) + { + _currentContrast = clamped; + OnPropertyChanged(); + } + } + } + + /// + /// Gets or sets minimum contrast value + /// + public int MinContrast { get; set; } + + /// + /// Gets or sets maximum contrast value + /// + public int MaxContrast { get; set; } = 100; + + /// + /// Gets or sets current volume (0-100) + /// + public int CurrentVolume + { + get => _currentVolume; + set + { + var clamped = Math.Clamp(value, MinVolume, MaxVolume); + if (_currentVolume != clamped) + { + _currentVolume = clamped; + OnPropertyChanged(); + } + } + } + + /// + /// Gets or sets minimum volume value + /// + public int MinVolume { get; set; } + + /// + /// Gets or sets maximum volume value + /// + public int MaxVolume { get; set; } = 100; + + /// + /// Gets or sets a value indicating whether the monitor is available/online + /// + public bool IsAvailable + { + get => _isAvailable; + set + { + if (_isAvailable != value) + { + _isAvailable = value; + OnPropertyChanged(); + } + } + } + + /// + /// Gets or sets physical monitor handle (for DDC/CI) + /// + public IntPtr Handle { get; set; } = IntPtr.Zero; + + /// + /// Gets or sets instance name (used by WMI) + /// + public string InstanceName { get; set; } = string.Empty; + + /// + /// Gets or sets communication method (DDC/CI, WMI, HDR API, etc.) + /// + public string CommunicationMethod { get; set; } = string.Empty; + + /// + /// Gets or sets supported control methods + /// + public MonitorCapabilities Capabilities { get; set; } = MonitorCapabilities.None; + + /// + /// Gets or sets raw DDC/CI capabilities string (MCCS format) + /// + public string? CapabilitiesRaw { get; set; } + + /// + /// Gets or sets parsed VCP capabilities information + /// + public VcpCapabilities? VcpCapabilitiesInfo { get; set; } + + /// + /// Gets or sets last update time + /// + public DateTime LastUpdate { get; set; } = DateTime.Now; + + public event PropertyChangedEventHandler? PropertyChanged; + + protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + + public override string ToString() + { + return $"{Name} ({CommunicationMethod}) - {CurrentBrightness}%"; + } + + /// + /// Update monitor status + /// + public void UpdateStatus(int brightness, bool isAvailable = true) + { + IsAvailable = isAvailable; + if (isAvailable) + { + CurrentBrightness = brightness; + LastUpdate = DateTime.Now; + } + } + + /// + int IMonitorData.Brightness + { + get => CurrentBrightness; + set => CurrentBrightness = value; + } + + /// + int IMonitorData.Contrast + { + get => CurrentContrast; + set => CurrentContrast = value; + } + + /// + int IMonitorData.Volume + { + get => CurrentVolume; + set => CurrentVolume = value; + } + + /// + int IMonitorData.ColorTemperatureVcp + { + get => CurrentColorTemperature; + set => CurrentColorTemperature = value; + } + + /// + /// Gets or sets monitor number (1, 2, 3...) + /// + public int MonitorNumber { get; set; } + + /// + /// Gets or sets the GDI device name (e.g., "\\.\DISPLAY1"). + /// This is obtained from QueryDisplayConfig during discovery and should be used + /// for display settings APIs (EnumDisplaySettings, ChangeDisplaySettingsEx). + /// + public string GdiDeviceName { get; set; } = string.Empty; + + /// + /// Gets or sets monitor orientation (0=0, 1=90, 2=180, 3=270). + /// Fires PropertyChanged when value changes. + /// + public int Orientation + { + get => _orientation; + set + { + if (_orientation != value) + { + _orientation = value; + OnPropertyChanged(); + } + } + } + + /// + int IMonitorData.MonitorNumber + { + get => MonitorNumber; + set => MonitorNumber = value; + } + + /// + int IMonitorData.Orientation + { + get => Orientation; + set => Orientation = value; + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Models/MonitorCapabilities.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Models/MonitorCapabilities.cs new file mode 100644 index 0000000000..e961f32038 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Models/MonitorCapabilities.cs @@ -0,0 +1,52 @@ +// 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; + +namespace PowerDisplay.Common.Models +{ + /// + /// Monitor control capabilities flags + /// + [Flags] + public enum MonitorCapabilities + { + None = 0, + + /// + /// Supports brightness control + /// + Brightness = 1 << 0, + + /// + /// Supports contrast control + /// + Contrast = 1 << 1, + + /// + /// Supports DDC/CI protocol + /// + DdcCi = 1 << 2, + + /// + /// Supports WMI control + /// + Wmi = 1 << 3, + + /// + /// Supports HDR + /// + Hdr = 1 << 4, + + /// + /// Supports high-level monitor API + /// + HighLevel = 1 << 5, + + /// + /// Supports volume control + /// + Volume = 1 << 6, + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Models/MonitorOperationResult.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Models/MonitorOperationResult.cs new file mode 100644 index 0000000000..6905d7be44 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Models/MonitorOperationResult.cs @@ -0,0 +1,58 @@ +// 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; + +namespace PowerDisplay.Common.Models +{ + /// + /// Monitor operation result + /// + public readonly struct MonitorOperationResult + { + /// + /// Gets a value indicating whether the operation was successful + /// + public bool IsSuccess { get; } + + /// + /// Gets error message + /// + public string? ErrorMessage { get; } + + /// + /// Gets system error code + /// + public int? ErrorCode { get; } + + /// + /// Gets operation timestamp + /// + public DateTime Timestamp { get; } + + private MonitorOperationResult(bool isSuccess, string? errorMessage = null, int? errorCode = null) + { + IsSuccess = isSuccess; + ErrorMessage = errorMessage; + ErrorCode = errorCode; + Timestamp = DateTime.Now; + } + + /// + /// Creates a successful result + /// + public static MonitorOperationResult Success() => new(true); + + /// + /// Creates a failed result + /// + public static MonitorOperationResult Failure(string errorMessage, int? errorCode = null) + => new(false, errorMessage, errorCode); + + public override string ToString() + { + return IsSuccess ? "Success" : $"Failed: {ErrorMessage}"; + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Models/MonitorStateEntry.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Models/MonitorStateEntry.cs new file mode 100644 index 0000000000..8b1ade54a2 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Models/MonitorStateEntry.cs @@ -0,0 +1,52 @@ +// 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.Text.Json.Serialization; + +namespace PowerDisplay.Common.Models +{ + /// + /// Individual monitor state entry for JSON persistence. + /// Stores the current state of a monitor's adjustable parameters. + /// + public sealed class MonitorStateEntry + { + /// + /// Gets or sets the brightness level (0-100). + /// + [JsonPropertyName("brightness")] + public int Brightness { get; set; } + + /// + /// Gets or sets the color temperature VCP value. + /// + [JsonPropertyName("colorTemperature")] + public int ColorTemperatureVcp { get; set; } + + /// + /// Gets or sets the contrast level (0-100). + /// + [JsonPropertyName("contrast")] + public int Contrast { get; set; } + + /// + /// Gets or sets the volume level (0-100). + /// + [JsonPropertyName("volume")] + public int Volume { get; set; } + + /// + /// Gets or sets the raw capabilities string from DDC/CI. + /// + [JsonPropertyName("capabilitiesRaw")] + public string? CapabilitiesRaw { get; set; } + + /// + /// Gets or sets when this entry was last updated. + /// + [JsonPropertyName("lastUpdated")] + public DateTime LastUpdated { get; set; } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Models/MonitorStateFile.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Models/MonitorStateFile.cs new file mode 100644 index 0000000000..e761503649 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Models/MonitorStateFile.cs @@ -0,0 +1,30 @@ +// 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.Text.Json.Serialization; + +namespace PowerDisplay.Common.Models +{ + /// + /// Monitor state file structure for JSON persistence. + /// Contains all monitor states indexed by Monitor.Id. + /// + public sealed class MonitorStateFile + { + /// + /// Gets or sets the monitor states dictionary. + /// Key is the monitor's unique Id (e.g., "DDC_GSM5C6D_1", "WMI_BOE0900_2"). + /// + [JsonPropertyName("monitors")] + public Dictionary Monitors { get; set; } = new(); + + /// + /// Gets or sets when the file was last updated. + /// + [JsonPropertyName("lastUpdated")] + public DateTime LastUpdated { get; set; } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Models/PowerDisplayProfile.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Models/PowerDisplayProfile.cs new file mode 100644 index 0000000000..8944569201 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Models/PowerDisplayProfile.cs @@ -0,0 +1,60 @@ +// 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.Text.Json.Serialization; + +namespace PowerDisplay.Common.Models +{ + /// + /// Represents a PowerDisplay profile containing monitor settings + /// + public class PowerDisplayProfile + { + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("monitorSettings")] + public List MonitorSettings { get; set; } + + [JsonPropertyName("createdDate")] + public DateTime CreatedDate { get; set; } + + [JsonPropertyName("lastModified")] + public DateTime LastModified { get; set; } + + public PowerDisplayProfile() + { + Name = string.Empty; + MonitorSettings = new List(); + CreatedDate = DateTime.UtcNow; + LastModified = DateTime.UtcNow; + } + + public PowerDisplayProfile(string name, List monitorSettings) + { + Name = name; + MonitorSettings = monitorSettings ?? new List(); + CreatedDate = DateTime.UtcNow; + LastModified = DateTime.UtcNow; + } + + /// + /// Validates that the profile has at least one monitor configured + /// + public bool IsValid() + { + return !string.IsNullOrWhiteSpace(Name) && MonitorSettings != null && MonitorSettings.Count > 0; + } + + /// + /// Updates the last modified timestamp + /// + public void Touch() + { + LastModified = DateTime.UtcNow; + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Models/PowerDisplayProfiles.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Models/PowerDisplayProfiles.cs new file mode 100644 index 0000000000..6813089943 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Models/PowerDisplayProfiles.cs @@ -0,0 +1,100 @@ +// 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; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json.Serialization; + +namespace PowerDisplay.Common.Models +{ + /// + /// Container for all PowerDisplay profiles + /// + public class PowerDisplayProfiles + { + // NOTE: Custom profile concept has been removed. Profiles are now templates, not states. + // This constant is kept for backward compatibility (cleaning up legacy Custom profiles). + public const string CustomProfileName = "Custom"; + + [JsonPropertyName("profiles")] + public List Profiles { get; set; } + + [JsonPropertyName("lastUpdated")] + public DateTime LastUpdated { get; set; } + + public PowerDisplayProfiles() + { + Profiles = new List(); + LastUpdated = DateTime.UtcNow; + } + + /// + /// Gets the profile by name + /// + public PowerDisplayProfile? GetProfile(string name) + { + return Profiles.FirstOrDefault(p => p.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); + } + + /// + /// Adds or updates a profile + /// + public void SetProfile(PowerDisplayProfile profile) + { + if (profile == null || !profile.IsValid()) + { + throw new ArgumentException("Profile is invalid"); + } + + var existing = GetProfile(profile.Name); + if (existing != null) + { + Profiles.Remove(existing); + } + + profile.Touch(); + Profiles.Add(profile); + LastUpdated = DateTime.UtcNow; + } + + /// + /// Removes a profile by name + /// + public bool RemoveProfile(string name) + { + var profile = GetProfile(name); + if (profile != null) + { + Profiles.Remove(profile); + LastUpdated = DateTime.UtcNow; + return true; + } + + return false; + } + + /// + /// Checks if a profile name is valid and available + /// + public bool IsNameAvailable(string name, string? excludeName = null) + { + if (string.IsNullOrWhiteSpace(name)) + { + return false; + } + + // Check if name is already used (excluding the profile being renamed) + var existing = GetProfile(name); + if (existing != null && (excludeName == null || !existing.Name.Equals(excludeName, StringComparison.OrdinalIgnoreCase))) + { + return false; + } + + return true; + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Models/ProfileMonitorSetting.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Models/ProfileMonitorSetting.cs new file mode 100644 index 0000000000..d346657d7c --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Models/ProfileMonitorSetting.cs @@ -0,0 +1,51 @@ +// 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.Text.Json.Serialization; + +namespace PowerDisplay.Common.Models +{ + /// + /// Monitor settings for a specific profile + /// + public class ProfileMonitorSetting + { + /// + /// Gets or sets the monitor's unique identifier. + /// Format: "{Source}_{EdidId}_{MonitorNumber}" (e.g., "DDC_GSM5C6D_1"). + /// + [JsonPropertyName("monitorId")] + public string MonitorId { get; set; } + + [JsonPropertyName("brightness")] + public int? Brightness { get; set; } + + [JsonPropertyName("contrast")] + public int? Contrast { get; set; } + + [JsonPropertyName("volume")] + public int? Volume { get; set; } + + /// + /// Gets or sets the color temperature VCP preset value. + /// JSON property name kept as "colorTemperature" for backward compatibility. + /// + [JsonPropertyName("colorTemperature")] + public int? ColorTemperatureVcp { get; set; } + + public ProfileMonitorSetting() + { + MonitorId = string.Empty; + } + + public ProfileMonitorSetting(string monitorId, int? brightness = null, int? colorTemperatureVcp = null, int? contrast = null, int? volume = null) + { + MonitorId = monitorId; + Brightness = brightness; + ColorTemperatureVcp = colorTemperatureVcp; + Contrast = contrast; + Volume = volume; + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Models/VcpCapabilities.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Models/VcpCapabilities.cs new file mode 100644 index 0000000000..c30eccefe2 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Models/VcpCapabilities.cs @@ -0,0 +1,314 @@ +// 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; + +namespace PowerDisplay.Common.Models +{ + /// + /// DDC/CI VCP capabilities information + /// + public class VcpCapabilities + { + /// + /// Gets or sets raw capabilities string (MCCS format) + /// + public string Raw { get; set; } = string.Empty; + + /// + /// Gets or sets monitor model name from capabilities + /// + public string? Model { get; set; } + + /// + /// Gets or sets monitor type from capabilities (e.g., "LCD") + /// + public string? Type { get; set; } + + /// + /// Gets or sets mCCS protocol version + /// + public string? Protocol { get; set; } + + /// + /// Gets or sets mCCS version (e.g., "2.2", "2.1") + /// + public string? MccsVersion { get; set; } + + /// + /// Gets or sets supported command codes + /// + public List SupportedCommands { get; set; } = new(); + + /// + /// Gets or sets supported VCP codes with their information + /// + public Dictionary SupportedVcpCodes { get; set; } = new(); + + /// + /// Gets or sets window capabilities for PIP/PBP support + /// + public List Windows { get; set; } = new(); + + /// + /// Gets a value indicating whether check if display supports PIP/PBP windows + /// + public bool HasWindowSupport => Windows.Count > 0; + + /// + /// Check if a specific VCP code is supported + /// + public bool SupportsVcpCode(byte code) => SupportedVcpCodes.ContainsKey(code); + + /// + /// Get VCP code information + /// + public VcpCodeInfo? GetVcpCodeInfo(byte code) + { + return SupportedVcpCodes.TryGetValue(code, out var info) ? info : null; + } + + /// + /// Check if a VCP code supports discrete values + /// + public bool HasDiscreteValues(byte code) + { + var info = GetVcpCodeInfo(code); + return info?.HasDiscreteValues ?? false; + } + + /// + /// Get supported values for a VCP code + /// + public IReadOnlyList? GetSupportedValues(byte code) + { + return GetVcpCodeInfo(code)?.SupportedValues; + } + + /// + /// Get all VCP codes as hex strings, sorted by code value. + /// + /// List of hex strings like ["0x10", "0x12", "0x14"] + public List GetVcpCodesAsHexStrings() + { + var result = new List(SupportedVcpCodes.Count); + foreach (var kvp in SupportedVcpCodes) + { + result.Add($"0x{kvp.Key:X2}"); + } + + result.Sort(StringComparer.Ordinal); + return result; + } + + /// + /// Get all VCP codes sorted by code value. + /// + /// Sorted list of VcpCodeInfo + public IEnumerable GetSortedVcpCodes() + { + var sortedKeys = new List(SupportedVcpCodes.Keys); + sortedKeys.Sort(); + + foreach (var key in sortedKeys) + { + yield return SupportedVcpCodes[key]; + } + } + + /// + /// Gets creates an empty capabilities object + /// + public static VcpCapabilities Empty => new(); + + public override string ToString() + { + return $"Model: {Model}, VCP Codes: {SupportedVcpCodes.Count}"; + } + } + + /// + /// Information about a single VCP code + /// + public readonly struct VcpCodeInfo + { + /// + /// Gets vCP code (e.g., 0x10 for brightness) + /// + public byte Code { get; } + + /// + /// Gets human-readable name of the VCP code + /// + public string Name { get; } + + /// + /// Gets supported discrete values (empty if continuous range) + /// + public IReadOnlyList SupportedValues { get; } + + /// + /// Gets a value indicating whether this VCP code has discrete values + /// + public bool HasDiscreteValues => SupportedValues.Count > 0; + + /// + /// Gets a value indicating whether this VCP code supports a continuous range + /// + public bool IsContinuous => SupportedValues.Count == 0; + + /// + /// Gets the VCP code formatted as a hex string (e.g., "0x10"). + /// + public string FormattedCode => $"0x{Code:X2}"; + + /// + /// Gets the VCP code formatted with its name (e.g., "Brightness (0x10)"). + /// + public string FormattedTitle => $"{Name} ({FormattedCode})"; + + public VcpCodeInfo(byte code, string name, IReadOnlyList? supportedValues = null) + { + Code = code; + Name = name; + SupportedValues = supportedValues ?? Array.Empty(); + } + + public override string ToString() + { + if (HasDiscreteValues) + { + return $"0x{Code:X2} ({Name}): {string.Join(", ", SupportedValues)}"; + } + + return $"0x{Code:X2} ({Name}): Continuous"; + } + } + + /// + /// Window size (width and height) + /// + public readonly struct WindowSize + { + /// + /// Gets width in pixels + /// + public int Width { get; } + + /// + /// Gets height in pixels + /// + public int Height { get; } + + public WindowSize(int width, int height) + { + Width = width; + Height = height; + } + + public override string ToString() => $"{Width}x{Height}"; + } + + /// + /// Window area coordinates (top-left and bottom-right) + /// + public readonly struct WindowArea + { + /// + /// Gets top-left X coordinate + /// + public int X1 { get; } + + /// + /// Gets top-left Y coordinate + /// + public int Y1 { get; } + + /// + /// Gets bottom-right X coordinate + /// + public int X2 { get; } + + /// + /// Gets bottom-right Y coordinate + /// + public int Y2 { get; } + + /// + /// Gets width of the area + /// + public int Width => X2 - X1; + + /// + /// Gets height of the area + /// + public int Height => Y2 - Y1; + + public WindowArea(int x1, int y1, int x2, int y2) + { + X1 = x1; + Y1 = y1; + X2 = x2; + Y2 = y2; + } + + public override string ToString() => $"({X1},{Y1})-({X2},{Y2})"; + } + + /// + /// Window capability information for PIP/PBP displays + /// + public readonly struct WindowCapability + { + /// + /// Gets window number (1, 2, 3, etc.) + /// + public int WindowNumber { get; } + + /// + /// Gets window type (e.g., "PIP", "PBP") + /// + public string Type { get; } + + /// + /// Gets window area coordinates + /// + public WindowArea Area { get; } + + /// + /// Gets maximum window size + /// + public WindowSize MaxSize { get; } + + /// + /// Gets minimum window size + /// + public WindowSize MinSize { get; } + + /// + /// Gets window identifier + /// + public int WindowId { get; } + + public WindowCapability( + int windowNumber, + string type, + WindowArea area, + WindowSize maxSize, + WindowSize minSize, + int windowId) + { + WindowNumber = windowNumber; + Type = type ?? string.Empty; + Area = area; + MaxSize = maxSize; + MinSize = minSize; + WindowId = windowId; + } + + public override string ToString() => + $"Window{WindowNumber}: Type={Type}, Area={Area}, Max={MaxSize}, Min={MinSize}"; + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Models/VcpFeatureValue.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Models/VcpFeatureValue.cs new file mode 100644 index 0000000000..64c4b2d801 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Models/VcpFeatureValue.cs @@ -0,0 +1,77 @@ +// 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; + +namespace PowerDisplay.Common.Models +{ + /// + /// VCP feature value information structure. + /// Represents the current, minimum, and maximum values for a VCP (Virtual Control Panel) feature. + /// + public readonly struct VcpFeatureValue + { + /// + /// Gets current value + /// + public int Current { get; } + + /// + /// Gets minimum value + /// + public int Minimum { get; } + + /// + /// Gets maximum value + /// + public int Maximum { get; } + + /// + /// Gets a value indicating whether the value information is valid + /// + public bool IsValid { get; } + + /// + /// Gets timestamp when the value information was obtained + /// + public DateTime Timestamp { get; } + + public VcpFeatureValue(int current, int minimum, int maximum) + { + Current = current; + Minimum = minimum; + Maximum = maximum; + IsValid = current >= minimum && current <= maximum && maximum > minimum; + Timestamp = DateTime.Now; + } + + public VcpFeatureValue(int current, int maximum) + : this(current, 0, maximum) + { + } + + /// + /// Gets creates invalid value information + /// + public static VcpFeatureValue Invalid => new(-1, -1, -1); + + /// + /// Converts value to percentage (0-100) + /// + public int ToPercentage() + { + if (!IsValid || Maximum == Minimum) + { + return 0; + } + + return (int)Math.Round((double)(Current - Minimum) * 100 / (Maximum - Minimum)); + } + + public override string ToString() + { + return IsValid ? $"{Current}/{Maximum} ({ToPercentage()}%)" : "Invalid"; + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/NativeMethods.json b/src/modules/powerdisplay/PowerDisplay.Lib/NativeMethods.json new file mode 100644 index 0000000000..450ecacafd --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/NativeMethods.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://aka.ms/CsWin32.schema.json", + "public": true, + "allowMarshaling": false +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/NativeMethods.txt b/src/modules/powerdisplay/PowerDisplay.Lib/NativeMethods.txt new file mode 100644 index 0000000000..769c001fed --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/NativeMethods.txt @@ -0,0 +1,9 @@ +// Structs and constants only - functions use LibraryImport for AOT compatibility +// CsWin32 generates blittable types when allowMarshaling: false + +// Note: All structs are manually defined in NativeStructures.cs with proper blittable layouts +// This file is intentionally left with only LUID as a minimal test +// Full DISPLAYCONFIG_* types need helper methods like GetViewGdiDeviceName() which CsWin32 doesn't provide + +// Only request LUID from CsWin32 (other types manually defined in NativeStructures.cs) +LUID diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/PathConstants.cs b/src/modules/powerdisplay/PowerDisplay.Lib/PathConstants.cs new file mode 100644 index 0000000000..4dd92d9666 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/PathConstants.cs @@ -0,0 +1,122 @@ +// 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.IO; + +namespace PowerDisplay.Common +{ + /// + /// Centralized path constants for PowerDisplay module. + /// Provides unified access to all file and folder paths used by PowerDisplay and related integrations. + /// + public static class PathConstants + { + private static readonly Lazy _localAppDataPath = new Lazy( + () => Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData)); + + private static readonly Lazy _powerToysBasePath = new Lazy( + () => Path.Combine(_localAppDataPath.Value, "Microsoft", "PowerToys")); + + /// + /// Gets the base PowerToys settings folder path. + /// Example: C:\Users\{User}\AppData\Local\Microsoft\PowerToys + /// + public static string PowerToysBasePath => _powerToysBasePath.Value; + + /// + /// Gets the PowerDisplay module folder path. + /// Example: C:\Users\{User}\AppData\Local\Microsoft\PowerToys\PowerDisplay + /// + public static string PowerDisplayFolderPath => Path.Combine(PowerToysBasePath, "PowerDisplay"); + + /// + /// Gets the PowerDisplay profiles file path. + /// Example: C:\Users\{User}\AppData\Local\Microsoft\PowerToys\PowerDisplay\profiles.json + /// + public static string ProfilesFilePath => Path.Combine(PowerDisplayFolderPath, ProfilesFileName); + + /// + /// Gets the PowerDisplay settings file path. + /// Example: C:\Users\{User}\AppData\Local\Microsoft\PowerToys\PowerDisplay\settings.json + /// + public static string SettingsFilePath => Path.Combine(PowerDisplayFolderPath, SettingsFileName); + + /// + /// Gets the LightSwitch module folder path. + /// Example: C:\Users\{User}\AppData\Local\Microsoft\PowerToys\LightSwitch + /// + public static string LightSwitchFolderPath => Path.Combine(PowerToysBasePath, "LightSwitch"); + + /// + /// Gets the LightSwitch settings file path. + /// Example: C:\Users\{User}\AppData\Local\Microsoft\PowerToys\LightSwitch\settings.json + /// + public static string LightSwitchSettingsFilePath => Path.Combine(LightSwitchFolderPath, SettingsFileName); + + /// + /// The name of the profiles file. + /// + public const string ProfilesFileName = "profiles.json"; + + /// + /// The name of the settings file. + /// + public const string SettingsFileName = "settings.json"; + + /// + /// The name of the monitor state file. + /// + public const string MonitorStateFileName = "monitor_state.json"; + + /// + /// Gets the monitor state file path. + /// Example: C:\Users\{User}\AppData\Local\Microsoft\PowerToys\PowerDisplay\monitor_state.json + /// + public static string MonitorStateFilePath => Path.Combine(PowerDisplayFolderPath, MonitorStateFileName); + + /// + /// Event name for LightSwitch light theme change notifications. + /// Signaled when LightSwitch switches to light mode. + /// Must match CommonSharedConstants::LIGHT_SWITCH_LIGHT_THEME_EVENT in shared_constants.h. + /// + public const string LightSwitchLightThemeEventName = "Local\\PowerToysLightSwitch-LightThemeEvent-50077121-2ffc-4841-9c86-ab1bd3f9baca"; + + /// + /// Event name for LightSwitch dark theme change notifications. + /// Signaled when LightSwitch switches to dark mode. + /// Must match CommonSharedConstants::LIGHT_SWITCH_DARK_THEME_EVENT in shared_constants.h. + /// + public const string LightSwitchDarkThemeEventName = "Local\\PowerToysLightSwitch-DarkThemeEvent-b3a835c0-eaa2-49b0-b8eb-f793e3df3368"; + + /// + /// Ensures the PowerDisplay folder exists. Creates it if necessary. + /// + /// The PowerDisplay folder path + public static string EnsurePowerDisplayFolderExists() + => EnsureFolderExists(PowerDisplayFolderPath); + + /// + /// Ensures the LightSwitch folder exists. Creates it if necessary. + /// + /// The LightSwitch folder path + public static string EnsureLightSwitchFolderExists() + => EnsureFolderExists(LightSwitchFolderPath); + + /// + /// Ensures the specified folder exists. Creates it if necessary. + /// + /// The folder path to ensure exists + /// The folder path + private static string EnsureFolderExists(string folderPath) + { + if (!Directory.Exists(folderPath)) + { + Directory.CreateDirectory(folderPath); + } + + return folderPath; + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/PowerDisplay.Lib.csproj b/src/modules/powerdisplay/PowerDisplay.Lib/PowerDisplay.Lib.csproj new file mode 100644 index 0000000000..e9f8cd3f05 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/PowerDisplay.Lib.csproj @@ -0,0 +1,43 @@ + + + + + + + + Library + PowerDisplay.Common + x64;ARM64 + false + false + true + enable + PowerDisplay.Lib + + + + false + false + true + false + false + + + + + + + all + + + + + + + + + + + + + diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Serialization/ProfileSerializationContext.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Serialization/ProfileSerializationContext.cs new file mode 100644 index 0000000000..198829f93e --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Serialization/ProfileSerializationContext.cs @@ -0,0 +1,34 @@ +// 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.Collections.Generic; +using System.Text.Json.Serialization; +using PowerDisplay.Common.Models; + +namespace PowerDisplay.Common.Serialization +{ + /// + /// JSON serialization context for PowerDisplay Profile types. + /// Provides source-generated serialization for Native AOT compatibility. + /// + [JsonSourceGenerationOptions( + WriteIndented = false, + DefaultIgnoreCondition = JsonIgnoreCondition.Never, + IncludeFields = true)] + + // Profile Types + [JsonSerializable(typeof(ProfileMonitorSetting))] + [JsonSerializable(typeof(List))] + [JsonSerializable(typeof(PowerDisplayProfile))] + [JsonSerializable(typeof(List))] + [JsonSerializable(typeof(PowerDisplayProfiles))] + + // Monitor State Types + [JsonSerializable(typeof(MonitorStateEntry))] + [JsonSerializable(typeof(MonitorStateFile))] + [JsonSerializable(typeof(Dictionary))] + public partial class ProfileSerializationContext : JsonSerializerContext + { + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Services/DisplayRotationService.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Services/DisplayRotationService.cs new file mode 100644 index 0000000000..2b3c6bc31d --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Services/DisplayRotationService.cs @@ -0,0 +1,171 @@ +// 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 +{ + /// + /// Service for controlling display rotation/orientation. + /// Uses ChangeDisplaySettingsEx API to change display orientation. + /// + public class DisplayRotationService + { + /// + /// Set display rotation for a specific monitor. + /// Uses GdiDeviceName from the Monitor object for accurate adapter targeting. + /// + /// Monitor object with GdiDeviceName + /// New orientation: 0=normal, 1=90°, 2=180°, 3=270° + /// Operation result + public MonitorOperationResult SetRotation(Monitor monitor, int newOrientation) + { + ArgumentNullException.ThrowIfNull(monitor); + + if (newOrientation < 0 || newOrientation > 3) + { + return MonitorOperationResult.Failure($"Invalid orientation value: {newOrientation}. Must be 0-3."); + } + + if (string.IsNullOrEmpty(monitor.GdiDeviceName)) + { + return MonitorOperationResult.Failure("Monitor has no GdiDeviceName"); + } + + return SetRotationByGdiDeviceName(monitor.GdiDeviceName, newOrientation); + } + + /// + /// Set display rotation by GDI device name. + /// + /// GDI device name (e.g., "\\.\DISPLAY1") + /// New orientation: 0=normal, 1=90°, 2=180°, 3=270° + /// Operation result + public unsafe MonitorOperationResult SetRotationByGdiDeviceName(string gdiDeviceName, int newOrientation) + { + if (string.IsNullOrEmpty(gdiDeviceName)) + { + return MonitorOperationResult.Failure("GDI device name is required"); + } + + try + { + // 1. Get current display settings + DevMode devMode = default; + devMode.DmSize = (short)sizeof(DevMode); + + if (!EnumDisplaySettings(gdiDeviceName, EnumCurrentSettings, &devMode)) + { + var error = GetLastError(); + Logger.LogError($"SetRotation: EnumDisplaySettings failed for {gdiDeviceName}, error: {error}"); + return MonitorOperationResult.Failure($"Failed to get current display settings for {gdiDeviceName}", (int)error); + } + + int currentOrientation = devMode.DmDisplayOrientation; + + // If already at target orientation, return success + if (currentOrientation == 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; + } + + // 3. Set new orientation + devMode.DmDisplayOrientation = newOrientation; + devMode.DmFields = DmDisplayOrientation | DmPelsWidth | DmPelsHeight; + + // 4. Test the settings first using CDS_TEST flag + int testResult = ChangeDisplaySettingsEx(gdiDeviceName, &devMode, IntPtr.Zero, CdsTest, IntPtr.Zero); + if (testResult != DispChangeSuccessful) + { + string errorMsg = GetChangeDisplaySettingsErrorMessage(testResult); + Logger.LogError($"SetRotation: Test failed for {gdiDeviceName}: {errorMsg}"); + return MonitorOperationResult.Failure($"Display settings test failed: {errorMsg}", testResult); + } + + // 5. Apply the settings (without CDS_UPDATEREGISTRY to make it temporary) + int result = ChangeDisplaySettingsEx(gdiDeviceName, &devMode, IntPtr.Zero, 0, IntPtr.Zero); + if (result != DispChangeSuccessful) + { + string errorMsg = GetChangeDisplaySettingsErrorMessage(result); + Logger.LogError($"SetRotation: Apply failed for {gdiDeviceName}: {errorMsg}"); + return MonitorOperationResult.Failure($"Failed to apply display settings: {errorMsg}", result); + } + + return MonitorOperationResult.Success(); + } + catch (Exception ex) + { + Logger.LogError($"SetRotation: Exception for {gdiDeviceName}: {ex.Message}"); + return MonitorOperationResult.Failure($"Exception while setting rotation: {ex.Message}"); + } + } + + /// + /// Get current orientation for a GDI device name. + /// + /// GDI device name (e.g., "\\.\DISPLAY1") + /// Current orientation (0-3), or -1 if query failed + public unsafe int GetCurrentOrientation(string gdiDeviceName) + { + if (string.IsNullOrEmpty(gdiDeviceName)) + { + return -1; + } + + try + { + DevMode devMode = default; + devMode.DmSize = (short)sizeof(DevMode); + + if (!EnumDisplaySettings(gdiDeviceName, EnumCurrentSettings, &devMode)) + { + return -1; + } + + return devMode.DmDisplayOrientation; + } + catch + { + return -1; + } + } + + /// + /// Get human-readable error message for ChangeDisplaySettings result code. + /// + 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}", + }; + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Services/MonitorStateManager.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Services/MonitorStateManager.cs new file mode 100644 index 0000000000..807102cffb --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Services/MonitorStateManager.cs @@ -0,0 +1,289 @@ +// 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.Concurrent; +using System.IO; +using System.Text.Json; +using System.Threading.Tasks; +using ManagedCommon; +using PowerDisplay.Common.Models; +using PowerDisplay.Common.Serialization; +using PowerDisplay.Common.Utils; + +namespace PowerDisplay.Common.Services +{ + /// + /// Manages monitor parameter state in a separate file from main settings. + /// This avoids FileSystemWatcher feedback loops by separating read-only config (settings.json) + /// from frequently updated state (monitor_state.json). + /// Simplified to use direct save strategy for reliability and simplicity (KISS principle). + /// + public partial class MonitorStateManager : IDisposable + { + private readonly string _stateFilePath; + private readonly ConcurrentDictionary _states = new(); + private readonly SimpleDebouncer _saveDebouncer; + + private bool _disposed; + private bool _isDirty; // Track pending changes for flush on dispose + private const int SaveDebounceMs = 2000; // Save 2 seconds after last update + + /// + /// Monitor state data (internal tracking, not serialized) + /// + private sealed class MonitorState + { + public int Brightness { get; set; } + + public int ColorTemperatureVcp { get; set; } + + public int Contrast { get; set; } + + public int Volume { get; set; } + + public string? CapabilitiesRaw { get; set; } + } + + /// + /// Initializes a new instance of the class. + /// Uses PathConstants for consistent path management. + /// + public MonitorStateManager() + { + // Use PathConstants for consistent path management + PathConstants.EnsurePowerDisplayFolderExists(); + _stateFilePath = PathConstants.MonitorStateFilePath; + + // Initialize debouncer for batching rapid updates (e.g., slider drag) + _saveDebouncer = new SimpleDebouncer(SaveDebounceMs); + + // Load existing state if available + LoadStateFromDisk(); + } + + /// + /// Update monitor parameter and schedule debounced save to disk. + /// Uses Monitor.Id as the stable key (e.g., "DDC_GSM5C6D_1", "WMI_BOE0900_2"). + /// Debounced-save strategy reduces disk I/O by batching rapid updates (e.g., during slider drag). + /// + /// The monitor's unique Id (e.g., "DDC_GSM5C6D_1"). + /// The property name to update (Brightness, ColorTemperature, Contrast, or Volume). + /// The new value. + public void UpdateMonitorParameter(string monitorId, string property, int value) + { + try + { + if (string.IsNullOrEmpty(monitorId)) + { + Logger.LogWarning($"Cannot update monitor parameter: monitorId is empty"); + return; + } + + var state = _states.GetOrAdd(monitorId, _ => new MonitorState()); + + // Update the specific property + bool shouldSave = true; + switch (property) + { + case "Brightness": + state.Brightness = value; + break; + case "ColorTemperature": + state.ColorTemperatureVcp = value; + break; + case "Contrast": + state.Contrast = value; + break; + case "Volume": + state.Volume = value; + break; + default: + Logger.LogWarning($"Unknown property: {property}"); + shouldSave = false; + break; + } + + if (shouldSave) + { + // Mark dirty for flush on dispose + _isDirty = true; + } + + // Schedule debounced save (SimpleDebouncer handles cancellation of previous calls) + if (shouldSave) + { + _saveDebouncer.Debounce(SaveStateToDiskAsync); + } + } + catch (Exception ex) + { + Logger.LogError($"Failed to update monitor parameter: {ex.Message}"); + } + } + + /// + /// Get saved parameters for a monitor using Monitor.Id. + /// + /// The monitor's unique Id (e.g., "DDC_GSM5C6D_1"). + /// A tuple of (Brightness, ColorTemperatureVcp, Contrast, Volume) or null if not found. + public (int Brightness, int ColorTemperatureVcp, int Contrast, int Volume)? GetMonitorParameters(string monitorId) + { + if (string.IsNullOrEmpty(monitorId)) + { + return null; + } + + if (_states.TryGetValue(monitorId, out var state)) + { + return (state.Brightness, state.ColorTemperatureVcp, state.Contrast, state.Volume); + } + + return null; + } + + /// + /// Load state from disk. + /// + private void LoadStateFromDisk() + { + try + { + if (!File.Exists(_stateFilePath)) + { + return; + } + + var json = File.ReadAllText(_stateFilePath); + var stateFile = JsonSerializer.Deserialize(json, ProfileSerializationContext.Default.MonitorStateFile); + + if (stateFile?.Monitors != null) + { + foreach (var kvp in stateFile.Monitors) + { + var monitorKey = kvp.Key; // Should be MonitorId (e.g., "GSM5C6D") + var entry = kvp.Value; + + _states[monitorKey] = new MonitorState + { + Brightness = entry.Brightness, + ColorTemperatureVcp = entry.ColorTemperatureVcp, + Contrast = entry.Contrast, + Volume = entry.Volume, + CapabilitiesRaw = entry.CapabilitiesRaw, + }; + } + } + } + catch (Exception ex) + { + Logger.LogError($"Failed to load monitor state: {ex.Message}"); + } + } + + /// + /// Save current state to disk immediately (async). + /// Called by timer after debounce period. + /// + private async Task SaveStateToDiskAsync() + { + try + { + if (_disposed) + { + return; + } + + var json = BuildStateJson(); + + // Write to disk asynchronously + await File.WriteAllTextAsync(_stateFilePath, json); + + // Clear dirty flag after successful save + _isDirty = false; + } + catch (Exception ex) + { + Logger.LogError($"Failed to save monitor state: {ex.Message}"); + } + } + + /// + /// Save current state to disk synchronously. + /// Called during Dispose to flush pending changes without risk of deadlock. + /// + private void SaveStateToDiskSync() + { + try + { + var json = BuildStateJson(); + + // Write to disk synchronously - safe for Dispose + File.WriteAllText(_stateFilePath, json); + } + catch (Exception ex) + { + Logger.LogError($"Failed to save monitor state (sync): {ex.Message}"); + } + } + + /// + /// Build the JSON string for state file. + /// Shared logic between async and sync save methods. + /// + /// JSON string for state file + private string BuildStateJson() + { + var now = DateTime.Now; + var stateFile = new MonitorStateFile + { + LastUpdated = now, + }; + + foreach (var kvp in _states) + { + var monitorId = kvp.Key; + var state = kvp.Value; + + stateFile.Monitors[monitorId] = new MonitorStateEntry + { + Brightness = state.Brightness, + ColorTemperatureVcp = state.ColorTemperatureVcp, + Contrast = state.Contrast, + Volume = state.Volume, + CapabilitiesRaw = state.CapabilitiesRaw, + LastUpdated = now, + }; + } + + return JsonSerializer.Serialize(stateFile, ProfileSerializationContext.Default.MonitorStateFile); + } + + /// + /// Disposes the MonitorStateManager, flushing any pending state changes. + /// + public void Dispose() + { + if (_disposed) + { + return; + } + + bool wasDirty = _isDirty; + _disposed = true; + _isDirty = false; + + // Dispose debouncer first to cancel any pending saves + _saveDebouncer?.Dispose(); + + // Flush any pending changes before disposing using sync method to avoid deadlock + if (wasDirty) + { + SaveStateToDiskSync(); + } + + GC.SuppressFinalize(this); + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Services/ProfileService.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Services/ProfileService.cs new file mode 100644 index 0000000000..094cb554fb --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Services/ProfileService.cs @@ -0,0 +1,250 @@ +// 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.IO; +using System.Text.Json; +using ManagedCommon; +using PowerDisplay.Common.Interfaces; +using PowerDisplay.Common.Models; +using PowerDisplay.Common.Serialization; + +namespace PowerDisplay.Common.Services +{ + /// + /// Centralized service for managing PowerDisplay profiles storage and retrieval. + /// Provides unified access to profile data for PowerDisplay, Settings UI, and LightSwitch modules. + /// Thread-safe and AOT-compatible. + /// + public class ProfileService : IProfileService + { + private const string LogPrefix = "[ProfileService]"; + private static readonly object _lock = new object(); + + /// + /// Gets the singleton instance of the ProfileService. + /// Use this for dependency injection or when interface-based access is needed. + /// + public static IProfileService Instance { get; } = new ProfileService(); + + /// + /// Initializes a new instance of the class. + /// Private constructor to enforce singleton pattern for instance-based access. + /// Static methods remain available for backward compatibility. + /// + private ProfileService() + { + } + + /// + /// Loads PowerDisplay profiles from disk. + /// Thread-safe operation with automatic legacy profile cleanup. + /// + /// PowerDisplayProfiles object, or a new empty instance if file doesn't exist or load fails + public static PowerDisplayProfiles LoadProfiles() + { + lock (_lock) + { + var (profiles, _) = LoadProfilesInternal(); + return profiles; + } + } + + /// + /// Saves PowerDisplay profiles to disk. + /// Thread-safe operation with automatic timestamp update and legacy profile cleanup. + /// + /// The profiles collection to save + /// True if save was successful, false otherwise + public static bool SaveProfiles(PowerDisplayProfiles profiles) + { + lock (_lock) + { + if (profiles == null) + { + Logger.LogWarning($"{LogPrefix} Cannot save null profiles"); + return false; + } + + var (success, _) = SaveProfilesInternal(profiles); + return success; + } + } + + /// + /// Adds or updates a profile in the collection and persists to disk. + /// Thread-safe operation. + /// + /// The profile to add or update + /// True if operation was successful, false otherwise + public static bool AddOrUpdateProfile(PowerDisplayProfile profile) + { + lock (_lock) + { + if (profile == null || !profile.IsValid()) + { + Logger.LogWarning($"{LogPrefix} Cannot add invalid profile"); + return false; + } + + var (profiles, _) = LoadProfilesInternal(); + profiles.SetProfile(profile); + + var (success, _) = SaveProfilesInternal(profiles); + return success; + } + } + + /// + /// Removes a profile by name and persists to disk. + /// Thread-safe operation. + /// + /// The name of the profile to remove + /// True if profile was found and removed, false otherwise + public static bool RemoveProfile(string profileName) + { + lock (_lock) + { + var (profiles, _) = LoadProfilesInternal(); + bool removed = profiles.RemoveProfile(profileName); + + if (removed) + { + SaveProfilesInternal(profiles); + } + + return removed; + } + } + + /// + /// Gets a profile by name. + /// Thread-safe operation. + /// + /// The name of the profile to retrieve + /// The profile if found, null otherwise + public static PowerDisplayProfile? GetProfile(string profileName) + { + lock (_lock) + { + var (profiles, _) = LoadProfilesInternal(); + return profiles.GetProfile(profileName); + } + } + + /// + /// Checks if the profiles file exists. + /// + /// True if profiles file exists, false otherwise + public static bool ProfilesFileExists() + { + try + { + return File.Exists(PathConstants.ProfilesFilePath); + } + catch (Exception ex) + { + Logger.LogError($"{LogPrefix} Error checking if profiles file exists: {ex.Message}"); + return false; + } + } + + /// + /// Gets the path to the profiles file. + /// + /// The full path to the profiles file + public static string GetProfilesFilePath() + { + return PathConstants.ProfilesFilePath; + } + + // Internal methods without lock for use within already-locked contexts + // Returns tuple with result and optional log message + private static (PowerDisplayProfiles Profiles, string? Message) LoadProfilesInternal() + { + try + { + var filePath = PathConstants.ProfilesFilePath; + + PathConstants.EnsurePowerDisplayFolderExists(); + + if (File.Exists(filePath)) + { + var json = File.ReadAllText(filePath); + var profiles = JsonSerializer.Deserialize(json, ProfileSerializationContext.Default.PowerDisplayProfiles); + + if (profiles != null) + { + profiles.Profiles.RemoveAll(p => p.Name.Equals(PowerDisplayProfiles.CustomProfileName, StringComparison.OrdinalIgnoreCase)); + return (profiles, $"Loaded {profiles.Profiles.Count} profiles from {filePath}"); + } + } + else + { + return (new PowerDisplayProfiles(), $"No profiles file found at {filePath}, returning empty collection"); + } + + return (new PowerDisplayProfiles(), null); + } + catch (Exception ex) + { + Logger.LogError($"{LogPrefix} Failed to load profiles: {ex.Message}"); + return (new PowerDisplayProfiles(), null); + } + } + + // Returns tuple with success status and optional log message + private static (bool Success, string? Message) SaveProfilesInternal(PowerDisplayProfiles profiles) + { + try + { + if (profiles == null) + { + return (false, null); + } + + PathConstants.EnsurePowerDisplayFolderExists(); + + profiles.Profiles.RemoveAll(p => p.Name.Equals(PowerDisplayProfiles.CustomProfileName, StringComparison.OrdinalIgnoreCase)); + profiles.LastUpdated = DateTime.UtcNow; + + var json = JsonSerializer.Serialize(profiles, ProfileSerializationContext.Default.PowerDisplayProfiles); + var filePath = PathConstants.ProfilesFilePath; + File.WriteAllText(filePath, json); + + return (true, $"Saved {profiles.Profiles.Count} profiles to {filePath}"); + } + catch (Exception ex) + { + Logger.LogError($"{LogPrefix} Failed to save profiles: {ex.Message}"); + return (false, null); + } + } + + // IProfileService Implementation + // Explicit interface implementation to satisfy IProfileService + // These methods delegate to the static methods for backward compatibility + + /// + PowerDisplayProfiles IProfileService.LoadProfiles() => LoadProfiles(); + + /// + bool IProfileService.SaveProfiles(PowerDisplayProfiles profiles) => SaveProfiles(profiles); + + /// + bool IProfileService.AddOrUpdateProfile(PowerDisplayProfile profile) => AddOrUpdateProfile(profile); + + /// + bool IProfileService.RemoveProfile(string profileName) => RemoveProfile(profileName); + + /// + PowerDisplayProfile? IProfileService.GetProfile(string profileName) => GetProfile(profileName); + + /// + bool IProfileService.ProfilesFileExists() => ProfilesFileExists(); + + /// + string IProfileService.GetProfilesFilePath() => GetProfilesFilePath(); + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Utils/ColorTemperatureHelper.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Utils/ColorTemperatureHelper.cs new file mode 100644 index 0000000000..43dc8c044f --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Utils/ColorTemperatureHelper.cs @@ -0,0 +1,79 @@ +// 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.Collections.Generic; +using System.Linq; +using PowerDisplay.Common.Drivers; +using PowerDisplay.Common.Models; + +namespace PowerDisplay.Common.Utils +{ + /// + /// Helper class for color temperature preset computation. + /// Provides shared logic for computing available color presets from VCP capabilities. + /// + public static class ColorTemperatureHelper + { + /// + /// Computes available color temperature presets from VCP value data. + /// + /// + /// Collection of tuples containing (VcpValue, Name) for each color temperature preset. + /// The VcpValue is the VCP value, Name is the name from capabilities string if available. + /// + /// Sorted list of ColorPresetItem objects. + public static List ComputeColorPresets(IEnumerable<(int VcpValue, string? Name)> colorTemperatureValues) + { + if (colorTemperatureValues == null) + { + return new List(); + } + + var presetList = new List(); + + foreach (var item in colorTemperatureValues) + { + var displayName = FormatColorTemperatureDisplayName(item.VcpValue, item.Name); + presetList.Add(new ColorPresetItem(item.VcpValue, displayName)); + } + + // Sort by VCP value for consistent ordering + return presetList.OrderBy(p => p.VcpValue).ToList(); + } + + /// + /// Formats a color temperature display name. + /// Uses VcpNames for standard VCP value mappings if no custom name is provided. + /// + /// The VCP value. + /// Optional custom name from capabilities string. + /// Formatted display name. + public static string FormatColorTemperatureDisplayName(int vcpValue, string? customName = null) + { + // Priority: use name from VCP capabilities if available + if (!string.IsNullOrEmpty(customName)) + { + return customName; + } + + // Fall back to standard VCP value name from shared library + return VcpNames.GetValueName(NativeConstants.VcpCodeSelectColorPreset, vcpValue) + ?? "Manufacturer Defined"; + } + + /// + /// Formats a display name for a custom (non-preset) color temperature value. + /// Used when the current value is not in the available preset list. + /// + /// The VCP value. + /// Formatted display name with "Custom" indicator. + public static string FormatCustomColorTemperatureDisplayName(int vcpValue) + { + var standardName = VcpNames.GetValueName(NativeConstants.VcpCodeSelectColorPreset, vcpValue); + return string.IsNullOrEmpty(standardName) + ? "Custom" + : $"{standardName} (Custom)"; + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Utils/EventHelper.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Utils/EventHelper.cs new file mode 100644 index 0000000000..0b3b82b8f7 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Utils/EventHelper.cs @@ -0,0 +1,46 @@ +// 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.Threading; +using ManagedCommon; + +namespace PowerDisplay.Common.Utils +{ + /// + /// Helper class for Windows named event operations. + /// Provides unified event signaling with consistent error handling and logging. + /// + public static class EventHelper + { + /// + /// Signals a named event. Creates the event if it doesn't exist. + /// + /// The name of the event to signal. + /// True if the event was signaled successfully, false otherwise. + public static bool SignalEvent(string eventName) + { + if (string.IsNullOrEmpty(eventName)) + { + Logger.LogWarning("[EventHelper] SignalEvent called with null or empty event name"); + return false; + } + + try + { + using var eventHandle = new EventWaitHandle( + false, + EventResetMode.AutoReset, + eventName); + eventHandle.Set(); + return true; + } + catch (Exception ex) + { + Logger.LogError($"[EventHelper] Failed to signal event '{eventName}': {ex.Message}"); + return false; + } + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Utils/MccsCapabilitiesParser.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Utils/MccsCapabilitiesParser.cs new file mode 100644 index 0000000000..201ead3965 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Utils/MccsCapabilitiesParser.cs @@ -0,0 +1,860 @@ +// 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.Globalization; +using System.Runtime.CompilerServices; +using ManagedCommon; +using PowerDisplay.Common.Models; + +namespace PowerDisplay.Common.Utils +{ + /// + /// Recursive descent parser for DDC/CI MCCS capabilities strings. + /// + /// MCCS Capabilities String Grammar (BNF): + /// + /// capabilities ::= '(' segment* ')' + /// segment ::= identifier '(' segment_content ')' + /// segment_content ::= text | vcp_entries | hex_list + /// vcp_entries ::= vcp_entry* + /// vcp_entry ::= hex_byte [ '(' hex_list ')' ] + /// hex_list ::= hex_byte* + /// hex_byte ::= [0-9A-Fa-f]{2} + /// identifier ::= [a-z_]+ + /// text ::= [^()]+ + /// + /// + /// Example input: + /// (prot(monitor)type(lcd)model(PD3220U)cmds(01 02 03)vcp(10 12 14(04 05) 60(11 12))mccs_ver(2.2)) + /// + public ref struct MccsCapabilitiesParser + { + private readonly List _errors; + private ReadOnlySpan _input; + private int _position; + + /// + /// Parse a capabilities string into structured VcpCapabilities. + /// + /// Raw MCCS capabilities string + /// Parsed capabilities object with any parse errors + public static MccsParseResult Parse(string? capabilitiesString) + { + if (string.IsNullOrWhiteSpace(capabilitiesString)) + { + return new MccsParseResult(VcpCapabilities.Empty, new List()); + } + + var parser = new MccsCapabilitiesParser(capabilitiesString); + return parser.ParseCapabilities(); + } + + private MccsCapabilitiesParser(string input) + { + _input = input.AsSpan(); + _position = 0; + _errors = new List(); + } + + /// + /// Main entry point: parse the entire capabilities string. + /// capabilities ::= '(' segment* ')' | segment* + /// + private MccsParseResult ParseCapabilities() + { + var capabilities = new VcpCapabilities + { + Raw = _input.ToString(), + }; + + SkipWhitespace(); + + // Handle optional outer parentheses (some monitors omit them) + bool hasOuterParens = Peek() == '('; + if (hasOuterParens) + { + Advance(); // consume '(' + } + + // Parse segments until end or closing paren + while (!IsAtEnd()) + { + SkipWhitespace(); + + if (IsAtEnd()) + { + break; + } + + if (Peek() == ')') + { + if (hasOuterParens) + { + Advance(); // consume closing ')' + } + + break; + } + + // Parse a segment: identifier(content) + var segment = ParseSegment(); + if (segment.HasValue) + { + ApplySegment(capabilities, segment.Value); + } + } + + return new MccsParseResult(capabilities, _errors); + } + + /// + /// Parse a single segment: identifier '(' content ')' + /// + private ParsedSegment? ParseSegment() + { + SkipWhitespace(); + + int startPos = _position; + + // Parse identifier + var identifier = ParseIdentifier(); + if (identifier.IsEmpty) + { + // Not a valid segment start - skip this character and continue + if (!IsAtEnd()) + { + Advance(); + } + + return null; + } + + SkipWhitespace(); + + // Expect '(' + if (Peek() != '(') + { + AddError($"Expected '(' after identifier '{identifier.ToString()}' at position {_position}"); + return null; + } + + Advance(); // consume '(' + + // Parse content until matching ')' + var content = ParseBalancedContent(); + + // Expect ')' + if (Peek() != ')') + { + AddError($"Expected ')' to close segment '{identifier.ToString()}' at position {_position}"); + } + else + { + Advance(); // consume ')' + } + + return new ParsedSegment(identifier.ToString(), content); + } + + /// + /// Parse content between balanced parentheses. + /// Handles nested parentheses correctly. + /// + private string ParseBalancedContent() + { + int start = _position; + int depth = 1; + + while (!IsAtEnd() && depth > 0) + { + char c = Peek(); + if (c == '(') + { + depth++; + } + else if (c == ')') + { + depth--; + if (depth == 0) + { + break; // Don't consume the closing paren + } + } + + Advance(); + } + + return _input.Slice(start, _position - start).ToString(); + } + + /// + /// Parse an identifier (letters, digits, and underscores). + /// identifier ::= [a-zA-Z0-9_]+ + /// Note: MCCS uses identifiers like window1, window2, etc. + /// + private ReadOnlySpan ParseIdentifier() + { + int start = _position; + + while (!IsAtEnd() && IsIdentifierChar(Peek())) + { + Advance(); + } + + return _input.Slice(start, _position - start); + } + + /// + /// Apply a parsed segment to the capabilities object. + /// + private void ApplySegment(VcpCapabilities capabilities, ParsedSegment segment) + { + switch (segment.Name.ToLowerInvariant()) + { + case "prot": + capabilities.Protocol = segment.Content.Trim(); + break; + + case "type": + capabilities.Type = segment.Content.Trim(); + break; + + case "model": + capabilities.Model = segment.Content.Trim(); + break; + + case "mccs_ver": + capabilities.MccsVersion = segment.Content.Trim(); + break; + + case "cmds": + capabilities.SupportedCommands = ParseHexList(segment.Content); + break; + + case "vcp": + capabilities.SupportedVcpCodes = ParseVcpEntries(segment.Content); + break; + + case "vcpname": + ParseVcpNames(segment.Content, capabilities); + break; + + default: + // Check for windowN pattern (window1, window2, etc.) + if (segment.Name.Length > 6 && + segment.Name.StartsWith("window", StringComparison.OrdinalIgnoreCase) && + int.TryParse(segment.Name.AsSpan(6), out int windowNum)) + { + var windowParser = new WindowParser(segment.Content); + var windowCap = windowParser.Parse(windowNum); + capabilities.Windows.Add(windowCap); + } + else + { + // Unknown segments are silently ignored + } + + break; + } + } + + /// + /// Parse VCP entries: vcp_entry* + /// vcp_entry ::= hex_byte [ '(' hex_list ')' ] + /// + private Dictionary ParseVcpEntries(string content) + { + var vcpCodes = new Dictionary(); + var parser = new VcpEntryParser(content); + + while (parser.TryParseEntry(out var entry)) + { + var name = VcpNames.GetCodeName(entry.Code); + vcpCodes[entry.Code] = new VcpCodeInfo(entry.Code, name, entry.Values); + } + + return vcpCodes; + } + + /// + /// Parse a hex byte list: hex_byte* + /// Handles both space-separated (01 02 03) and concatenated (010203) formats. + /// + private static List ParseHexList(string content) + { + var result = new List(); + var span = content.AsSpan(); + int i = 0; + + while (i < span.Length) + { + // Skip whitespace + while (i < span.Length && char.IsWhiteSpace(span[i])) + { + i++; + } + + if (i >= span.Length) + { + break; + } + + // Try to read two hex digits + if (i + 1 < span.Length && IsHexDigit(span[i]) && IsHexDigit(span[i + 1])) + { + if (byte.TryParse(span.Slice(i, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var value)) + { + result.Add(value); + } + + i += 2; + } + else + { + i++; // Skip invalid character + } + } + + return result; + } + + /// + /// Parse vcpname entries: hex_byte '(' name ')' + /// + private void ParseVcpNames(string content, VcpCapabilities capabilities) + { + // vcpname format: F0(Custom Name 1) F1(Custom Name 2) + var parser = new VcpNameParser(content); + + while (parser.TryParseEntry(out var code, out var name)) + { + if (capabilities.SupportedVcpCodes.TryGetValue(code, out var existingInfo)) + { + // Update existing entry with custom name + capabilities.SupportedVcpCodes[code] = new VcpCodeInfo(code, name, existingInfo.SupportedValues); + } + else + { + // Add new entry with custom name + capabilities.SupportedVcpCodes[code] = new VcpCodeInfo(code, name, Array.Empty()); + } + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private char Peek() => IsAtEnd() ? '\0' : _input[_position]; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void Advance() => _position++; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool IsAtEnd() => _position >= _input.Length; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void SkipWhitespace() + { + while (!IsAtEnd() && char.IsWhiteSpace(Peek())) + { + Advance(); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsIdentifierChar(char c) => + (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_'; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsHexDigit(char c) => + (c >= '0' && c <= '9') || (c >= 'A' && c <= 'F') || (c >= 'a' && c <= 'f'); + + private void AddError(string message) + { + _errors.Add(new ParseError(_position, message)); + Logger.LogWarning($"[MccsParser] {message}"); + } + } + + /// + /// Sub-parser for VCP entries within the vcp() segment. + /// + internal ref struct VcpEntryParser + { + private ReadOnlySpan _content; + private int _position; + + public VcpEntryParser(string content) + { + _content = content.AsSpan(); + _position = 0; + } + + /// + /// Try to parse the next VCP entry. + /// vcp_entry ::= hex_byte [ '(' hex_list ')' ] + /// + public bool TryParseEntry(out VcpEntry entry) + { + entry = default; + SkipWhitespace(); + + if (IsAtEnd()) + { + return false; + } + + // Parse hex byte (VCP code) + if (!TryParseHexByte(out var code)) + { + // Skip invalid character and try again + _position++; + return TryParseEntry(out entry); + } + + var values = new List(); + + SkipWhitespace(); + + // Check for optional value list + if (!IsAtEnd() && Peek() == '(') + { + _position++; // consume '(' + + // Parse values until ')' + while (!IsAtEnd() && Peek() != ')') + { + SkipWhitespace(); + + if (Peek() == ')') + { + break; + } + + if (TryParseHexByte(out var value)) + { + values.Add(value); + } + else + { + _position++; // Skip invalid character + } + } + + if (!IsAtEnd() && Peek() == ')') + { + _position++; // consume ')' + } + } + + entry = new VcpEntry(code, values); + return true; + } + + private bool TryParseHexByte(out byte value) + { + value = 0; + + if (_position + 1 >= _content.Length) + { + return false; + } + + if (!IsHexDigit(_content[_position]) || !IsHexDigit(_content[_position + 1])) + { + return false; + } + + if (byte.TryParse(_content.Slice(_position, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out value)) + { + _position += 2; + return true; + } + + return false; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private char Peek() => IsAtEnd() ? '\0' : _content[_position]; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool IsAtEnd() => _position >= _content.Length; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void SkipWhitespace() + { + while (!IsAtEnd() && char.IsWhiteSpace(Peek())) + { + _position++; + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsHexDigit(char c) => + (c >= '0' && c <= '9') || (c >= 'A' && c <= 'F') || (c >= 'a' && c <= 'f'); + } + + /// + /// Sub-parser for vcpname entries. + /// + internal ref struct VcpNameParser + { + private ReadOnlySpan _content; + private int _position; + + public VcpNameParser(string content) + { + _content = content.AsSpan(); + _position = 0; + } + + /// + /// Try to parse the next vcpname entry. + /// vcpname_entry ::= hex_byte '(' name ')' + /// + public bool TryParseEntry(out byte code, out string name) + { + code = 0; + name = string.Empty; + + SkipWhitespace(); + + if (IsAtEnd()) + { + return false; + } + + // Parse hex byte + if (!TryParseHexByte(out code)) + { + _position++; + return TryParseEntry(out code, out name); + } + + SkipWhitespace(); + + // Expect '(' + if (IsAtEnd() || Peek() != '(') + { + return false; + } + + _position++; // consume '(' + + // Parse name until ')' + int start = _position; + while (!IsAtEnd() && Peek() != ')') + { + _position++; + } + + name = _content.Slice(start, _position - start).ToString().Trim(); + + if (!IsAtEnd() && Peek() == ')') + { + _position++; // consume ')' + } + + return true; + } + + private bool TryParseHexByte(out byte value) + { + value = 0; + + if (_position + 1 >= _content.Length) + { + return false; + } + + if (!IsHexDigit(_content[_position]) || !IsHexDigit(_content[_position + 1])) + { + return false; + } + + if (byte.TryParse(_content.Slice(_position, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out value)) + { + _position += 2; + return true; + } + + return false; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private char Peek() => IsAtEnd() ? '\0' : _content[_position]; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool IsAtEnd() => _position >= _content.Length; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void SkipWhitespace() + { + while (!IsAtEnd() && char.IsWhiteSpace(Peek())) + { + _position++; + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsHexDigit(char c) => + (c >= '0' && c <= '9') || (c >= 'A' && c <= 'F') || (c >= 'a' && c <= 'f'); + } + + /// + /// Sub-parser for window segment content. + /// Parses: type(PIP) area(25 25 1895 1175) max(640 480) min(10 10) window(10) + /// + internal ref struct WindowParser + { + private ReadOnlySpan _content; + private int _position; + + public WindowParser(string content) + { + _content = content.AsSpan(); + _position = 0; + } + + /// + /// Parse window segment content into a WindowCapability. + /// + public WindowCapability Parse(int windowNumber) + { + string type = string.Empty; + var area = default(WindowArea); + var maxSize = default(WindowSize); + var minSize = default(WindowSize); + int windowId = 0; + + // Parse sub-segments: type(...) area(...) max(...) min(...) window(...) + while (!IsAtEnd()) + { + SkipWhitespace(); + if (IsAtEnd()) + { + break; + } + + var subSegment = ParseSubSegment(); + if (subSegment.HasValue) + { + switch (subSegment.Value.Name.ToLowerInvariant()) + { + case "type": + type = subSegment.Value.Content.Trim(); + break; + case "area": + area = ParseArea(subSegment.Value.Content); + break; + case "max": + maxSize = ParseSize(subSegment.Value.Content); + break; + case "min": + minSize = ParseSize(subSegment.Value.Content); + break; + case "window": + _ = int.TryParse(subSegment.Value.Content.Trim(), out windowId); + break; + } + } + } + + return new WindowCapability(windowNumber, type, area, maxSize, minSize, windowId); + } + + private (string Name, string Content)? ParseSubSegment() + { + int start = _position; + + // Parse identifier + while (!IsAtEnd() && IsIdentifierChar(Peek())) + { + _position++; + } + + if (_position == start) + { + // No identifier found, skip character + if (!IsAtEnd()) + { + _position++; + } + + return null; + } + + var name = _content.Slice(start, _position - start).ToString(); + + SkipWhitespace(); + + // Expect '(' + if (IsAtEnd() || Peek() != '(') + { + return null; + } + + _position++; // consume '(' + + // Parse content with balanced parentheses + int contentStart = _position; + int depth = 1; + + while (!IsAtEnd() && depth > 0) + { + char c = Peek(); + if (c == '(') + { + depth++; + } + else if (c == ')') + { + depth--; + if (depth == 0) + { + break; + } + } + + _position++; + } + + var content = _content.Slice(contentStart, _position - contentStart).ToString(); + + if (!IsAtEnd() && Peek() == ')') + { + _position++; // consume ')' + } + + return (name, content); + } + + private static WindowArea ParseArea(string content) + { + var values = ParseIntList(content); + if (values.Length >= 4) + { + return new WindowArea(values[0], values[1], values[2], values[3]); + } + + return default; + } + + private static WindowSize ParseSize(string content) + { + var values = ParseIntList(content); + if (values.Length >= 2) + { + return new WindowSize(values[0], values[1]); + } + + return default; + } + + private static int[] ParseIntList(string content) + { + var parts = content.Split(' ', StringSplitOptions.RemoveEmptyEntries); + var result = new List(parts.Length); + + foreach (var part in parts) + { + if (int.TryParse(part.Trim(), out int value)) + { + result.Add(value); + } + } + + return result.ToArray(); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private char Peek() => IsAtEnd() ? '\0' : _content[_position]; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool IsAtEnd() => _position >= _content.Length; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void SkipWhitespace() + { + while (!IsAtEnd() && char.IsWhiteSpace(Peek())) + { + _position++; + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsIdentifierChar(char c) => + (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_'; + } + + /// + /// Represents a parsed segment from the capabilities string. + /// + internal readonly struct ParsedSegment + { + public string Name { get; } + + public string Content { get; } + + public ParsedSegment(string name, string content) + { + Name = name; + Content = content; + } + } + + /// + /// Represents a parsed VCP entry. + /// + internal readonly struct VcpEntry + { + public byte Code { get; } + + public IReadOnlyList Values { get; } + + public VcpEntry(byte code, IReadOnlyList values) + { + Code = code; + Values = values; + } + } + + /// + /// Represents a parse error with position information. + /// + public readonly struct ParseError + { + public int Position { get; } + + public string Message { get; } + + public ParseError(int position, string message) + { + Position = position; + Message = message; + } + + public override string ToString() => $"[{Position}] {Message}"; + } + + /// + /// Result of parsing MCCS capabilities string. + /// + public sealed class MccsParseResult + { + public VcpCapabilities Capabilities { get; } + + public IReadOnlyList Errors { get; } + + public bool HasErrors => Errors.Count > 0; + + public bool IsValid => !HasErrors && Capabilities.SupportedVcpCodes.Count > 0; + + public MccsParseResult(VcpCapabilities capabilities, IReadOnlyList errors) + { + Capabilities = capabilities; + Errors = errors; + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Utils/PnpIdHelper.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Utils/PnpIdHelper.cs new file mode 100644 index 0000000000..dd1665d0f5 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Utils/PnpIdHelper.cs @@ -0,0 +1,86 @@ +// 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.Frozen; +using System.Collections.Generic; + +namespace PowerDisplay.Common.Utils; + +/// +/// Helper class for mapping PnP (Plug and Play) manufacturer IDs to display names. +/// PnP IDs are 3-character codes assigned by Microsoft to hardware manufacturers. +/// See: https://uefi.org/pnp_id_list +/// +public static class PnpIdHelper +{ + /// + /// Map of common laptop/monitor manufacturer PnP IDs to display names. + /// Only includes manufacturers known to produce laptops with internal displays. + /// + private static readonly FrozenDictionary ManufacturerNames = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + // Major laptop manufacturers + { "ACR", "Acer" }, + { "AUO", "AU Optronics" }, + { "BOE", "BOE" }, + { "CMN", "Chi Mei Innolux" }, + { "DEL", "Dell" }, + { "HWP", "HP" }, + { "IVO", "InfoVision" }, + { "LEN", "Lenovo" }, + { "LGD", "LG Display" }, + { "NCP", "Nanjing CEC Panda" }, + { "SAM", "Samsung" }, + { "SDC", "Samsung Display" }, + { "SEC", "Samsung Electronics" }, + { "SHP", "Sharp" }, + { "AUS", "ASUS" }, + { "MSI", "MSI" }, + { "APP", "Apple" }, + { "SNY", "Sony" }, + { "PHL", "Philips" }, + { "HSD", "HannStar" }, + { "CPT", "Chunghwa Picture Tubes" }, + { "QDS", "Quanta Display" }, + { "TMX", "Tianma Microelectronics" }, + { "CSO", "CSOT" }, + + // Microsoft Surface + { "MSF", "Microsoft" }, + }.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase); + + /// + /// Extract the 3-character PnP manufacturer ID from an EDID ID. + /// + /// EDID ID like "LEN4038" or "BOE0900". + /// The 3-character PnP ID (e.g., "LEN"), or null if invalid. + public static string? ExtractPnpId(string? edidId) + { + if (string.IsNullOrEmpty(edidId) || edidId.Length < 3) + { + return null; + } + + // PnP ID is the first 3 characters + return edidId.Substring(0, 3).ToUpperInvariant(); + } + + /// + /// Get a user-friendly display name for an internal display based on its EDID ID. + /// + /// EDID ID like "LEN4038" or "BOE0900". + /// Display name like "Lenovo Built-in Display" or "Built-in Display" as fallback. + public static string GetBuiltInDisplayName(string? edidId) + { + var pnpId = ExtractPnpId(edidId); + + if (pnpId != null && ManufacturerNames.TryGetValue(pnpId, out var manufacturer)) + { + return $"{manufacturer} Built-in Display"; + } + + return "Built-in Display"; + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Utils/ProfileHelper.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Utils/ProfileHelper.cs new file mode 100644 index 0000000000..d7274824d7 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Utils/ProfileHelper.cs @@ -0,0 +1,66 @@ +// 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.Globalization; + +namespace PowerDisplay.Common.Utils +{ + /// + /// Helper class for profile management. + /// Provides shared logic for generating unique profile names and other profile-related operations. + /// + public static class ProfileHelper + { + /// + /// Default base name for new profiles. + /// + public const string DefaultProfileBaseName = "Profile"; + + /// + /// Maximum counter value when generating unique profile names. + /// + private const int MaxProfileCounter = 1000; + + /// + /// Generates a unique profile name that doesn't conflict with existing names. + /// Uses the format "Profile N" where N is an incrementing number. + /// + /// Set of existing profile names to avoid conflicts. + /// Optional base name to use (defaults to "Profile"). + /// A unique profile name. + public static string GenerateUniqueProfileName(ISet existingNames, string? baseName = null) + { + if (existingNames == null) + { + existingNames = new HashSet(StringComparer.OrdinalIgnoreCase); + } + + var nameBase = string.IsNullOrEmpty(baseName) ? DefaultProfileBaseName : baseName; + + // Start with base name without number + if (!existingNames.Contains(nameBase)) + { + return nameBase; + } + + // Try "Profile 2", "Profile 3", etc. + int counter = 2; + while (counter < MaxProfileCounter) + { + var candidateName = string.Format(CultureInfo.InvariantCulture, "{0} {1}", nameBase, counter); + if (!existingNames.Contains(candidateName)) + { + return candidateName; + } + + counter++; + } + + // Fallback with timestamp if somehow we hit the limit + return string.Format(CultureInfo.InvariantCulture, "{0} {1}", nameBase, DateTime.Now.Ticks); + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Utils/SimpleDebouncer.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Utils/SimpleDebouncer.cs new file mode 100644 index 0000000000..3c55a5d992 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Utils/SimpleDebouncer.cs @@ -0,0 +1,127 @@ +// 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.Threading; +using System.Threading.Tasks; +using ManagedCommon; + +namespace PowerDisplay.Common.Utils +{ + /// + /// Simple debouncer that delays execution of an action until a quiet period. + /// Replaces the complex PropertyUpdateQueue with a much simpler approach (KISS principle). + /// + public partial class SimpleDebouncer : IDisposable + { + private readonly int _delayMs; + private readonly object _lock = new object(); + private CancellationTokenSource? _cts; + private bool _disposed; + + /// + /// Initializes a new instance of the class. + /// Create a debouncer with specified delay + /// + /// Delay in milliseconds before executing action + public SimpleDebouncer(int delayMs = 300) + { + _delayMs = delayMs; + } + + /// + /// Debounce an async action. Cancels previous invocation if still pending. + /// + /// Async action to execute after delay + public void Debounce(Func action) + { + _ = DebounceAsync(action); + } + + /// + /// Debounce a synchronous action + /// + public void Debounce(Action action) + { + _ = DebounceAsync(() => + { + action(); + return Task.CompletedTask; + }); + } + + private async Task DebounceAsync(Func action) + { + if (_disposed) + { + return; + } + + CancellationTokenSource cts; + CancellationTokenSource? oldCts = null; + + lock (_lock) + { + // Store old CTS to dispose later + oldCts = _cts; + + // Create new CTS + _cts = new CancellationTokenSource(); + cts = _cts; + } + + // Dispose old CTS outside the lock to avoid blocking + if (oldCts != null) + { + try + { + oldCts.Cancel(); + oldCts.Dispose(); + } + catch (ObjectDisposedException) + { + // Expected if CTS was already disposed + } + } + + try + { + // Wait for quiet period + await Task.Delay(_delayMs, cts.Token).ConfigureAwait(false); + + // Execute action if not cancelled + if (!cts.Token.IsCancellationRequested) + { + await action().ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + // Expected when debouncing - a newer call cancelled this one + } + catch (Exception ex) + { + Logger.LogError($"Debounced action failed: {ex.Message}"); + } + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + lock (_lock) + { + _disposed = true; + _cts?.Cancel(); + _cts?.Dispose(); + _cts = null; + } + + GC.SuppressFinalize(this); + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Utils/VcpNames.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Utils/VcpNames.cs new file mode 100644 index 0000000000..2d4fed19c6 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Utils/VcpNames.cs @@ -0,0 +1,428 @@ +// 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.Collections.Generic; + +namespace PowerDisplay.Common.Utils +{ + /// + /// Provides human-readable names for VCP codes and their values based on MCCS v2.2a specification. + /// Combines VCP code names (e.g., 0x10 = "Brightness") and VCP value names (e.g., 0x14:0x05 = "6500K"). + /// + public static class VcpNames + { + /// + /// VCP code to name mapping + /// + private static readonly Dictionary CodeNames = new() + { + // Control codes (0x00-0x0F) + { 0x00, "Code Page" }, + { 0x01, "Degauss" }, + { 0x02, "New Control Value" }, + { 0x03, "Soft Controls" }, + + // Preset operations (0x04-0x0A) + { 0x04, "Restore Factory Defaults" }, + { 0x05, "Restore Brightness and Contrast" }, + { 0x06, "Restore Factory Geometry" }, + { 0x08, "Restore Color Defaults" }, + { 0x0A, "Restore Factory TV Defaults" }, + + // Color temperature codes + { 0x0B, "Color Temperature Increment" }, + { 0x0C, "Color Temperature Request" }, + { 0x0E, "Clock" }, + { 0x0F, "Color Saturation" }, + + // Image adjustment codes + { 0x10, "Brightness" }, + { 0x11, "Flesh Tone Enhancement" }, + { 0x12, "Contrast" }, + { 0x13, "Backlight Control" }, + { 0x14, "Select Color Preset" }, + { 0x16, "Video Gain: Red" }, + { 0x17, "User Color Vision Compensation" }, + { 0x18, "Video Gain: Green" }, + { 0x1A, "Video Gain: Blue" }, + { 0x1C, "Focus" }, + { 0x1E, "Auto Setup" }, + { 0x1F, "Auto Color Setup" }, + + // Geometry controls (0x20-0x4C) + { 0x20, "Horizontal Position" }, + { 0x22, "Horizontal Size" }, + { 0x24, "Horizontal Pincushion" }, + { 0x26, "Horizontal Pincushion Balance" }, + { 0x28, "Horizontal Convergence R/B" }, + { 0x29, "Horizontal Convergence M/G" }, + { 0x2A, "Horizontal Linearity" }, + { 0x2C, "Horizontal Linearity Balance" }, + { 0x2E, "Gray Scale Expansion" }, + { 0x30, "Vertical Position" }, + { 0x32, "Vertical Size" }, + { 0x34, "Vertical Pincushion" }, + { 0x36, "Vertical Pincushion Balance" }, + { 0x38, "Vertical Convergence R/B" }, + { 0x39, "Vertical Convergence M/G" }, + { 0x3A, "Vertical Linearity" }, + { 0x3C, "Vertical Linearity Balance" }, + { 0x3E, "Clock Phase" }, + + // Miscellaneous codes + { 0x40, "Horizontal Parallelogram" }, + { 0x41, "Vertical Parallelogram" }, + { 0x42, "Horizontal Keystone" }, + { 0x43, "Vertical Keystone" }, + { 0x44, "Rotation" }, + { 0x46, "Top Corner Flare" }, + { 0x48, "Top Corner Hook" }, + { 0x4A, "Bottom Corner Flare" }, + { 0x4C, "Bottom Corner Hook" }, + + // Advanced codes + { 0x52, "Active Control" }, + { 0x54, "Performance Preservation" }, + { 0x56, "Horizontal Moire" }, + { 0x58, "Vertical Moire" }, + { 0x59, "6 Axis Saturation: Red" }, + { 0x5A, "6 Axis Saturation: Yellow" }, + { 0x5B, "6 Axis Saturation: Green" }, + { 0x5C, "6 Axis Saturation: Cyan" }, + { 0x5D, "6 Axis Saturation: Blue" }, + { 0x5E, "6 Axis Saturation: Magenta" }, + + // Input source codes + { 0x60, "Input Source" }, + { 0x62, "Audio Speaker Volume" }, + { 0x63, "Speaker Select" }, + { 0x64, "Audio: Microphone Volume" }, + { 0x66, "Ambient Light Sensor" }, + { 0x6B, "Backlight Level: White" }, + { 0x6C, "Video Black Level: Red" }, + { 0x6D, "Backlight Level: Red" }, + { 0x6E, "Video Black Level: Green" }, + { 0x6F, "Backlight Level: Green" }, + { 0x70, "Video Black Level: Blue" }, + { 0x71, "Backlight Level: Blue" }, + { 0x72, "Gamma" }, + { 0x73, "LUT Size" }, + { 0x74, "Single Point LUT Operation" }, + { 0x75, "Block LUT Operation" }, + { 0x76, "Remote Procedure Call" }, + { 0x78, "Display Identification Data Operation" }, + { 0x7A, "Adjust Focal Plane" }, + { 0x7C, "Adjust Zoom" }, + { 0x7E, "Trapezoid" }, + { 0x80, "Keystone" }, + { 0x82, "Horizontal Mirror (Flip)" }, + { 0x84, "Vertical Mirror (Flip)" }, + + // Image adjustment codes (0x86-0x9F) + { 0x86, "Display Scaling" }, + { 0x87, "Sharpness" }, + { 0x88, "Velocity Scan Modulation" }, + { 0x8A, "Color Saturation" }, + { 0x8B, "TV Channel Up/Down" }, + { 0x8C, "TV Sharpness" }, + { 0x8D, "Audio Mute/Screen Blank" }, + { 0x8E, "TV Contrast" }, + { 0x8F, "Audio Treble" }, + { 0x90, "Hue" }, + { 0x91, "Audio Bass" }, + { 0x92, "TV Black Level/Luminance" }, + { 0x93, "Audio Balance L/R" }, + { 0x94, "Audio Processor Mode" }, + { 0x95, "Window Position(TL_X)" }, + { 0x96, "Window Position(TL_Y)" }, + { 0x97, "Window Position(BR_X)" }, + { 0x98, "Window Position(BR_Y)" }, + { 0x99, "Window Background" }, + { 0x9A, "6 Axis Hue Control: Red" }, + { 0x9B, "6 Axis Hue Control: Yellow" }, + { 0x9C, "6 Axis Hue Control: Green" }, + { 0x9D, "6 Axis Hue Control: Cyan" }, + { 0x9E, "6 Axis Hue Control: Blue" }, + { 0x9F, "6 Axis Hue Control: Magenta" }, + + // Window control codes + { 0xA0, "Auto Setup On/Off" }, + { 0xA2, "Auto Color Setup On/Off" }, + { 0xA4, "Window Mask Control" }, + { 0xA5, "Window Select" }, + { 0xA6, "Window Size" }, + { 0xA7, "Window Transparency" }, + { 0xA8, "Window Control" }, + { 0xAA, "Screen Orientation" }, + { 0xAC, "Horizontal Frequency" }, + { 0xAE, "Vertical Frequency" }, + + // Misc advanced codes + { 0xB0, "Settings" }, + { 0xB2, "Flat Panel Sub-Pixel Layout" }, + { 0xB4, "Source Timing Mode" }, + { 0xB6, "Display Technology Type" }, + { 0xB7, "Monitor Status" }, + { 0xB8, "Packet Count" }, + { 0xB9, "Monitor X Origin" }, + { 0xBA, "Monitor Y Origin" }, + { 0xBB, "Header Error Count" }, + { 0xBC, "Body CRC Error Count" }, + { 0xBD, "Client ID" }, + { 0xBE, "Link Control" }, + + // Display controller codes + { 0xC0, "Display Usage Time" }, + { 0xC2, "Display Firmware Level" }, + { 0xC4, "Display Descriptor Length" }, + { 0xC5, "Transmit Display Descriptor" }, + { 0xC6, "Enable Display of 'Display Descriptor'" }, + { 0xC8, "Display Controller Type" }, + { 0xC9, "Display Firmware Level" }, + { 0xCA, "OSD" }, + { 0xCC, "OSD Language" }, + { 0xCD, "Status Indicators" }, + { 0xCE, "Auxiliary Display Size" }, + { 0xCF, "Auxiliary Display Data" }, + { 0xD0, "Output Select" }, + { 0xD2, "Asset Tag" }, + { 0xD4, "Stereo Video Mode" }, + { 0xD6, "Power Mode" }, + { 0xD7, "Auxiliary Power Output" }, + { 0xD8, "Scan Mode" }, + { 0xD9, "Image Mode" }, + { 0xDA, "On Screen Display" }, + { 0xDB, "Backlight Level: White" }, + { 0xDC, "Display Application" }, + { 0xDD, "Application Enable Key" }, + { 0xDE, "Scratch Pad" }, + { 0xDF, "VCP Version" }, + + // Manufacturer specific codes (0xE0-0xFF) + // Per MCCS 2.2a: "The 32 control codes E0h through FFh have been + // allocated to allow manufacturers to issue their own specific controls." + { 0xE0, "Manufacturer Specific" }, + { 0xE1, "Manufacturer Specific" }, + { 0xE2, "Manufacturer Specific" }, + { 0xE3, "Manufacturer Specific" }, + { 0xE4, "Manufacturer Specific" }, + { 0xE5, "Manufacturer Specific" }, + { 0xE6, "Manufacturer Specific" }, + { 0xE7, "Manufacturer Specific" }, + { 0xE8, "Manufacturer Specific" }, + { 0xE9, "Manufacturer Specific" }, + { 0xEA, "Manufacturer Specific" }, + { 0xEB, "Manufacturer Specific" }, + { 0xEC, "Manufacturer Specific" }, + { 0xED, "Manufacturer Specific" }, + { 0xEE, "Manufacturer Specific" }, + { 0xEF, "Manufacturer Specific" }, + { 0xF0, "Manufacturer Specific" }, + { 0xF1, "Manufacturer Specific" }, + { 0xF2, "Manufacturer Specific" }, + { 0xF3, "Manufacturer Specific" }, + { 0xF4, "Manufacturer Specific" }, + { 0xF5, "Manufacturer Specific" }, + { 0xF6, "Manufacturer Specific" }, + { 0xF7, "Manufacturer Specific" }, + { 0xF8, "Manufacturer Specific" }, + { 0xF9, "Manufacturer Specific" }, + { 0xFA, "Manufacturer Specific" }, + { 0xFB, "Manufacturer Specific" }, + { 0xFC, "Manufacturer Specific" }, + { 0xFD, "Manufacturer Specific" }, + { 0xFE, "Manufacturer Specific" }, + { 0xFF, "Manufacturer Specific" }, + }; + + /// + /// Get the friendly name for a VCP code + /// + /// VCP code (e.g., 0x10) + /// Friendly name, or hex representation if unknown + public static string GetCodeName(byte code) + { + return CodeNames.TryGetValue(code, out var name) ? name : $"Unknown (0x{code:X2})"; + } + + // Dictionary> + private static readonly Dictionary> ValueNames = new() + { + // 0x14: Select Color Preset + [0x14] = new Dictionary + { + [0x01] = "sRGB", + [0x02] = "Display Native", + [0x03] = "4000K", + [0x04] = "5000K", + [0x05] = "6500K", + [0x06] = "7500K", + [0x07] = "8200K", + [0x08] = "9300K", + [0x09] = "10000K", + [0x0A] = "11500K", + [0x0B] = "User 1", + [0x0C] = "User 2", + [0x0D] = "User 3", + }, + + // 0x60: Input Source + [0x60] = new Dictionary + { + [0x01] = "VGA-1", + [0x02] = "VGA-2", + [0x03] = "DVI-1", + [0x04] = "DVI-2", + [0x05] = "Composite Video 1", + [0x06] = "Composite Video 2", + [0x07] = "S-Video-1", + [0x08] = "S-Video-2", + [0x09] = "Tuner-1", + [0x0A] = "Tuner-2", + [0x0B] = "Tuner-3", + [0x0C] = "Component Video 1", + [0x0D] = "Component Video 2", + [0x0E] = "Component Video 3", + [0x0F] = "DisplayPort-1", + [0x10] = "DisplayPort-2", + [0x11] = "HDMI-1", + [0x12] = "HDMI-2", + [0x1B] = "USB-C", + }, + + // 0xD6: Power Mode + [0xD6] = new Dictionary + { + [0x01] = "On", + [0x02] = "Standby", + [0x03] = "Suspend", + [0x04] = "Off (DPM)", + [0x05] = "Off (Hard)", + }, + + // 0x8D: Audio Mute + [0x8D] = new Dictionary + { + [0x01] = "Muted", + [0x02] = "Unmuted", + }, + + // 0xDC: Display Application + [0xDC] = new Dictionary + { + [0x00] = "Standard/Default", + [0x01] = "Productivity", + [0x02] = "Mixed", + [0x03] = "Movie", + [0x04] = "User Defined", + [0x05] = "Games", + [0x06] = "Sports", + [0x07] = "Professional (calibration)", + [0x08] = "Standard/Default with intermediate power consumption", + [0x09] = "Standard/Default with low power consumption", + [0x0A] = "Demonstration", + [0xF0] = "Dynamic Contrast", + }, + + // 0xCC: OSD Language + [0xCC] = new Dictionary + { + [0x01] = "Chinese (traditional, Hantai)", + [0x02] = "English", + [0x03] = "French", + [0x04] = "German", + [0x05] = "Italian", + [0x06] = "Japanese", + [0x07] = "Korean", + [0x08] = "Portuguese (Portugal)", + [0x09] = "Russian", + [0x0A] = "Spanish", + [0x0B] = "Swedish", + [0x0C] = "Turkish", + [0x0D] = "Chinese (simplified, Kantai)", + [0x0E] = "Portuguese (Brazil)", + [0x0F] = "Arabic", + [0x10] = "Bulgarian", + [0x11] = "Croatian", + [0x12] = "Czech", + [0x13] = "Danish", + [0x14] = "Dutch", + [0x15] = "Estonian", + [0x16] = "Finnish", + [0x17] = "Greek", + [0x18] = "Hebrew", + [0x19] = "Hindi", + [0x1A] = "Hungarian", + [0x1B] = "Latvian", + [0x1C] = "Lithuanian", + [0x1D] = "Norwegian", + [0x1E] = "Polish", + [0x1F] = "Romanian", + [0x20] = "Serbian", + [0x21] = "Slovak", + [0x22] = "Slovenian", + [0x23] = "Thai", + [0x24] = "Ukrainian", + [0x25] = "Vietnamese", + }, + + // 0x62: Audio Speaker Volume + [0x62] = new Dictionary + { + [0x00] = "Mute", + + // Other values are continuous + }, + + // 0xDB: Image Mode (Dell monitors) + [0xDB] = new Dictionary + { + [0x00] = "Standard", + [0x01] = "Multimedia", + [0x02] = "Movie", + [0x03] = "Game", + [0x04] = "Sports", + [0x05] = "Color Temperature", + [0x06] = "Custom Color", + [0x07] = "ComfortView", + }, + }; + + /// + /// Get human-readable name for a VCP value + /// + /// VCP code (e.g., 0x14) + /// Value to translate + /// Name string like "sRGB" or null if unknown + public static string? GetValueName(byte vcpCode, int value) + { + if (ValueNames.TryGetValue(vcpCode, out var codeValues)) + { + if (codeValues.TryGetValue(value, out var name)) + { + return name; + } + } + + return null; + } + + /// + /// Get formatted display name for a VCP value (with hex value in parentheses) + /// + /// VCP code (e.g., 0x14) + /// Value to translate + /// Formatted string like "sRGB (0x01)" or "0x01" if unknown + public static string GetFormattedValueName(byte vcpCode, int value) + { + var name = GetValueName(vcpCode, value); + if (name != null) + { + return $"{name} (0x{value:X2})"; + } + + return $"0x{value:X2}"; + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay/Assets/PowerDisplay/PowerDisplay.ico b/src/modules/powerdisplay/PowerDisplay/Assets/PowerDisplay/PowerDisplay.ico new file mode 100644 index 0000000000..a9f170a8bd Binary files /dev/null and b/src/modules/powerdisplay/PowerDisplay/Assets/PowerDisplay/PowerDisplay.ico differ diff --git a/src/modules/powerdisplay/PowerDisplay/Configuration/AppConstants.cs b/src/modules/powerdisplay/PowerDisplay/Configuration/AppConstants.cs new file mode 100644 index 0000000000..94ab75444a --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/Configuration/AppConstants.cs @@ -0,0 +1,34 @@ +// 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. + +namespace PowerDisplay.Configuration +{ + /// + /// Application-wide constants and configuration values + /// + public static class AppConstants + { + /// + /// UI layout and timing constants + /// + public static class UI + { + // Window dimensions + public const int WindowWidth = 362; + public const int MinWindowHeight = 100; + public const int MaxWindowHeight = 650; + public const int WindowRightMargin = 12; + + /// + /// Icon glyph for internal/laptop displays (WMI) + /// + public const string InternalMonitorGlyph = "\uE7F8"; + + /// + /// Icon glyph for external monitors (DDC/CI) + /// + public const string ExternalMonitorGlyph = "\uE7F4"; + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay/GlobalUsings.cs b/src/modules/powerdisplay/PowerDisplay/GlobalUsings.cs new file mode 100644 index 0000000000..d6c14983d5 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/GlobalUsings.cs @@ -0,0 +1,9 @@ +// 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.Runtime.CompilerServices; + +// Enable compile-time marshalling for all P/Invoke declarations +// This allows LibraryImport to handle array marshalling and achieve 100% coverage +[assembly: DisableRuntimeMarshalling] diff --git a/src/modules/powerdisplay/PowerDisplay/Helpers/DisplayChangeWatcher.cs b/src/modules/powerdisplay/PowerDisplay/Helpers/DisplayChangeWatcher.cs new file mode 100644 index 0000000000..74650a4373 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/Helpers/DisplayChangeWatcher.cs @@ -0,0 +1,333 @@ +// 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.Threading; +using System.Threading.Tasks; +using ManagedCommon; +using Microsoft.UI.Dispatching; +using Microsoft.Win32; +using Windows.Devices.Display; +using Windows.Devices.Enumeration; + +namespace PowerDisplay.Helpers; + +/// +/// Watches for display/monitor connection changes using WinRT DeviceWatcher. +/// Triggers DisplayChanged event when monitors are added, removed, or updated. +/// +public sealed partial class DisplayChangeWatcher : IDisposable +{ + private readonly DispatcherQueue _dispatcherQueue; + private readonly TimeSpan _debounceDelay; + + private DeviceWatcher? _deviceWatcher; + private CancellationTokenSource? _debounceCts; + private bool _isRunning; + private bool _disposed; + private bool _initialEnumerationComplete; + + /// + /// Event triggered when display configuration changes (after debounce period). + /// + public event EventHandler? DisplayChanged; + + /// + /// Initializes a new instance of the class. + /// + /// The dispatcher queue for UI thread marshalling. + /// Delay before triggering DisplayChanged event. This allows hardware to stabilize after monitor plug/unplug. + public DisplayChangeWatcher(DispatcherQueue dispatcherQueue, TimeSpan debounceDelay) + { + _dispatcherQueue = dispatcherQueue ?? throw new ArgumentNullException(nameof(dispatcherQueue)); + _debounceDelay = debounceDelay; + SystemEvents.PowerModeChanged += OnPowerModeChanged; + } + + /// + /// Gets a value indicating whether the watcher is currently running. + /// + public bool IsRunning => _isRunning; + + /// + /// Starts watching for display changes. + /// + public void Start() + { + ObjectDisposedException.ThrowIf(_disposed, this); + + if (_isRunning) + { + return; + } + + try + { + // Get the device selector for display monitors + string selector = DisplayMonitor.GetDeviceSelector(); + + // Create the device watcher + _deviceWatcher = DeviceInformation.CreateWatcher(selector); + + // Subscribe to events + _deviceWatcher.Added += OnDeviceAdded; + _deviceWatcher.Removed += OnDeviceRemoved; + _deviceWatcher.Updated += OnDeviceUpdated; + _deviceWatcher.EnumerationCompleted += OnEnumerationCompleted; + _deviceWatcher.Stopped += OnWatcherStopped; + + // Reset state before starting (must be before Start() to avoid race) + _initialEnumerationComplete = false; + _isRunning = true; + + // Start watching + _deviceWatcher.Start(); + } + catch (Exception ex) + { + Logger.LogError($"[DisplayChangeWatcher] Failed to start: {ex.Message}"); + _isRunning = false; + } + } + + /// + /// Stops watching for display changes. + /// + public void Stop() + { + if (!_isRunning || _deviceWatcher == null) + { + return; + } + + try + { + // Cancel any pending debounce + CancelDebounce(); + + // Stop the watcher + _deviceWatcher.Stop(); + } + catch (Exception ex) + { + Logger.LogError($"[DisplayChangeWatcher] Error stopping watcher: {ex.Message}"); + } + } + + private void OnDeviceAdded(DeviceWatcher sender, DeviceInformation args) + { + // Dispatch to UI thread to ensure thread-safe state access + _dispatcherQueue.TryEnqueue(() => + { + // Ignore events during initial enumeration or after disposal + if (_disposed || !_initialEnumerationComplete) + { + return; + } + + ScheduleDisplayChanged(); + }); + } + + private void OnDeviceRemoved(DeviceWatcher sender, DeviceInformationUpdate args) + { + // Dispatch to UI thread to ensure thread-safe state access + _dispatcherQueue.TryEnqueue(() => + { + // Ignore events during initial enumeration or after disposal + if (_disposed || !_initialEnumerationComplete) + { + return; + } + + ScheduleDisplayChanged(); + }); + } + + private void OnDeviceUpdated(DeviceWatcher sender, DeviceInformationUpdate args) + { + // Only trigger refresh for significant updates, not every property change. + // For now, we'll skip updates to avoid excessive refreshes. + // The Added and Removed events are the primary triggers for monitor changes. + } + + private void OnEnumerationCompleted(DeviceWatcher sender, object args) + { + // Dispatch to UI thread to ensure thread-safe state access + _dispatcherQueue.TryEnqueue(() => + { + _initialEnumerationComplete = true; + }); + } + + private void OnWatcherStopped(DeviceWatcher sender, object args) + { + // Dispatch to UI thread to ensure thread-safe state access + _dispatcherQueue.TryEnqueue(() => + { + _isRunning = false; + + // If not disposed, this is an unexpected stop (e.g., during sleep/wake) + // Try to auto-restart the watcher + if (!_disposed) + { + Logger.LogInfo("[DisplayChangeWatcher] Watcher stopped unexpectedly, attempting restart"); + + // Clean up the old watcher + CleanupDeviceWatcher(); + + // Restart after a short delay to allow system to stabilize + Task.Run(async () => + { + await Task.Delay(TimeSpan.FromSeconds(1)); + _dispatcherQueue.TryEnqueue(() => + { + if (!_disposed && !_isRunning) + { + Start(); + } + }); + }); + } + else + { + _initialEnumerationComplete = false; + } + }); + } + + /// + /// Handles system power mode changes (suspend/resume). + /// + private void OnPowerModeChanged(object? sender, PowerModeChangedEventArgs e) + { + if (_disposed) + { + return; + } + + if (e.Mode == PowerModes.Resume) + { + Logger.LogInfo("[DisplayChangeWatcher] System resumed from sleep, scheduling display refresh"); + + // Schedule a display refresh after system resumes + // Use a longer delay to allow hardware to fully reinitialize + _dispatcherQueue.TryEnqueue(() => + { + if (!_disposed) + { + // Trigger a display changed event after wake-up + // The debounce mechanism will handle rapid successive events + ScheduleDisplayChanged(); + } + }); + } + } + + /// + /// Cleans up the current device watcher and unsubscribes from events. + /// + private void CleanupDeviceWatcher() + { + if (_deviceWatcher != null) + { + try + { + _deviceWatcher.Added -= OnDeviceAdded; + _deviceWatcher.Removed -= OnDeviceRemoved; + _deviceWatcher.Updated -= OnDeviceUpdated; + _deviceWatcher.EnumerationCompleted -= OnEnumerationCompleted; + _deviceWatcher.Stopped -= OnWatcherStopped; + } + catch + { + // Ignore errors during cleanup + } + + _deviceWatcher = null; + } + } + + /// + /// Schedules a DisplayChanged event with debouncing. + /// Multiple rapid changes will only trigger one event after the debounce period. + /// + private void ScheduleDisplayChanged() + { + // Cancel any pending debounce + CancelDebounce(); + + // Create new cancellation token + _debounceCts = new CancellationTokenSource(); + var token = _debounceCts.Token; + + // Schedule the event after debounce delay + Task.Run(async () => + { + try + { + await Task.Delay(_debounceDelay, token); + + if (!token.IsCancellationRequested) + { + // Dispatch to UI thread + _dispatcherQueue.TryEnqueue(() => + { + if (!_disposed) + { + DisplayChanged?.Invoke(this, EventArgs.Empty); + } + }); + } + } + catch (OperationCanceledException) + { + // Debounce was cancelled by a newer event, this is expected + } + catch (Exception ex) + { + Logger.LogError($"[DisplayChangeWatcher] Error in debounce task: {ex.Message}"); + } + }); + } + + private void CancelDebounce() + { + try + { + _debounceCts?.Cancel(); + _debounceCts?.Dispose(); + _debounceCts = null; + } + catch (ObjectDisposedException) + { + // Already disposed, ignore + } + } + + /// + /// Disposes resources used by the watcher. + /// + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + + // Unsubscribe from power mode changes + SystemEvents.PowerModeChanged -= OnPowerModeChanged; + + // Stop watching + Stop(); + + // Unsubscribe from device watcher events + CleanupDeviceWatcher(); + + // Cancel debounce + CancelDebounce(); + } +} diff --git a/src/modules/powerdisplay/PowerDisplay/Helpers/HotkeyService.cs b/src/modules/powerdisplay/PowerDisplay/Helpers/HotkeyService.cs new file mode 100644 index 0000000000..63ef27c054 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/Helpers/HotkeyService.cs @@ -0,0 +1,177 @@ +// 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.Runtime.InteropServices; +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library; +using WinRT.Interop; + +namespace PowerDisplay.Helpers +{ + /// + /// Service for handling hotkey registration in-process. + /// Uses RegisterHotKey Win32 API instead of Runner's centralized mechanism + /// to avoid IPC timing issues (CmdPal pattern). + /// + internal sealed partial class HotkeyService : IDisposable + { + private const int HotkeyId = 9001; + + private readonly SettingsUtils _settingsUtils; + private readonly Action _hotkeyAction; + + private nint _hwnd; + private nint _originalWndProc; + + // Must keep delegate reference to prevent GC collection + private WndProcDelegate? _hotkeyWndProc; + private bool _isRegistered; + private bool _disposed; + + public HotkeyService(SettingsUtils settingsUtils, Action hotkeyAction) + { + _settingsUtils = settingsUtils; + _hotkeyAction = hotkeyAction; + } + + /// + /// Initialize the hotkey service with a window handle. + /// Must be called after window is created. + /// + /// The WinUI window to attach to. + public void Initialize(Microsoft.UI.Xaml.Window window) + { + _hwnd = WindowNative.GetWindowHandle(window); + + // LOAD BEARING: If you don't stick the pointer to the WndProc into a + // member (and instead use a local), then the pointer we marshal + // into the WindowLongPtr will be useless after we leave this function, + // and our WndProc will explode. + _hotkeyWndProc = HotkeyWndProc; + var wndProcPointer = Marshal.GetFunctionPointerForDelegate(_hotkeyWndProc); + _originalWndProc = SetWindowLongPtrNative(_hwnd, GwlWndProc, wndProcPointer); + + // Register hotkey based on current settings + ReloadSettings(); + } + + /// + /// Reload settings and re-register hotkey. + /// Call this when settings change. + /// + public void ReloadSettings() + { + UnregisterHotkey(); + + var settings = _settingsUtils.GetSettingsOrDefault(PowerDisplaySettings.ModuleName); + var hotkey = settings?.Properties?.ActivationShortcut; + + if (hotkey == null || !hotkey.IsValid()) + { + return; + } + + RegisterHotkey(hotkey); + } + + private void RegisterHotkey(HotkeySettings hotkey) + { + if (_hwnd == 0) + { + Logger.LogWarning("[HotkeyService] Cannot register hotkey: window handle not set"); + return; + } + + // Build modifiers using bit flags + uint modifiers = ModNoRepeat + | (hotkey.Win ? ModWin : 0) + | (hotkey.Ctrl ? ModControl : 0) + | (hotkey.Alt ? ModAlt : 0) + | (hotkey.Shift ? ModShift : 0); + + if (RegisterHotKeyNative(_hwnd, HotkeyId, modifiers, (uint)hotkey.Code)) + { + _isRegistered = true; + } + else + { + Logger.LogError($"[HotkeyService] Failed to register hotkey: {hotkey}, error={Marshal.GetLastWin32Error()}"); + } + } + + private void UnregisterHotkey() + { + if (!_isRegistered || _hwnd == 0) + { + return; + } + + bool success = UnregisterHotKeyNative(_hwnd, HotkeyId); + + if (!success) + { + var error = Marshal.GetLastWin32Error(); + Logger.LogWarning($"[HotkeyService] Failed to unregister hotkey, error={error}"); + } + + _isRegistered = false; + } + + private nint HotkeyWndProc(nint hwnd, uint uMsg, nuint wParam, nint lParam) + { + if (uMsg == WmHotkey && (int)wParam == HotkeyId) + { + try + { + _hotkeyAction?.Invoke(); + } + catch (Exception ex) + { + Logger.LogError($"[HotkeyService] Hotkey action failed: {ex.Message}"); + } + + return 0; + } + + return CallWindowProcNative(_originalWndProc, hwnd, uMsg, wParam, lParam); + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + UnregisterHotkey(); + _disposed = true; + } + + // P/Invoke constants + private const int GwlWndProc = -4; + private const uint WmHotkey = 0x0312; + + // HOT_KEY_MODIFIERS flags + private const uint ModAlt = 0x0001; + private const uint ModControl = 0x0002; + private const uint ModShift = 0x0004; + private const uint ModWin = 0x0008; + private const uint ModNoRepeat = 0x4000; + + [LibraryImport("user32.dll", EntryPoint = "SetWindowLongPtrW")] + private static partial nint SetWindowLongPtrNative(nint hWnd, int nIndex, nint dwNewLong); + + [LibraryImport("user32.dll", EntryPoint = "CallWindowProcW")] + private static partial nint CallWindowProcNative(nint lpPrevWndFunc, nint hWnd, uint msg, nuint wParam, nint lParam); + + [LibraryImport("user32.dll", EntryPoint = "RegisterHotKey", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool RegisterHotKeyNative(nint hWnd, int id, uint fsModifiers, uint vk); + + [LibraryImport("user32.dll", EntryPoint = "UnregisterHotKey", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool UnregisterHotKeyNative(nint hWnd, int id); + } +} diff --git a/src/modules/powerdisplay/PowerDisplay/Helpers/MonitorManager.cs b/src/modules/powerdisplay/PowerDisplay/Helpers/MonitorManager.cs new file mode 100644 index 0000000000..1f9a353303 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/Helpers/MonitorManager.cs @@ -0,0 +1,460 @@ +// 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.Linq; +using System.Threading; +using System.Threading.Tasks; +using ManagedCommon; +using PowerDisplay.Common.Drivers; +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 Monitor = PowerDisplay.Common.Models.Monitor; + +namespace PowerDisplay.Helpers +{ + /// + /// Monitor manager for unified control of all monitors + /// No interface abstraction - KISS principle (only one implementation needed) + /// + public partial class MonitorManager : IDisposable + { + private readonly List _monitors = new(); + private readonly Dictionary _monitorLookup = new(); + private readonly SemaphoreSlim _discoveryLock = new(1, 1); + private readonly DisplayRotationService _rotationService = new(); + + // Controllers stored by type for O(1) lookup based on CommunicationMethod + private DdcCiController? _ddcController; + private WmiController? _wmiController; + private bool _disposed; + + public IReadOnlyList Monitors => _monitors.AsReadOnly(); + + public MonitorManager() + { + // Initialize controllers + InitializeControllers(); + } + + /// + /// Initialize controllers + /// + private void InitializeControllers() + { + try + { + // DDC/CI controller (external monitors) + _ddcController = new DdcCiController(); + } + catch (Exception ex) + { + Logger.LogWarning($"Failed to initialize DDC/CI controller: {ex.Message}"); + } + + try + { + // WMI controller (internal monitors) + // Always create - DiscoverMonitorsAsync returns empty list if WMI is unavailable + _wmiController = new WmiController(); + } + catch (Exception ex) + { + Logger.LogWarning($"Failed to initialize WMI controller: {ex.Message}"); + } + } + + /// + /// Discover all monitors from all controllers. + /// Each controller is responsible for fully initializing its monitors + /// (including brightness, capabilities, input source, color temperature, etc.) + /// + public async Task> DiscoverMonitorsAsync(CancellationToken cancellationToken = default) + { + await _discoveryLock.WaitAsync(cancellationToken); + + try + { + var discoveredMonitors = await DiscoverFromAllControllersAsync(cancellationToken); + + // Update collections + _monitors.Clear(); + _monitorLookup.Clear(); + + var sortedMonitors = discoveredMonitors + .OrderBy(m => m.MonitorNumber) + .ToList(); + + _monitors.AddRange(sortedMonitors); + foreach (var monitor in sortedMonitors) + { + _monitorLookup[monitor.Id] = monitor; + } + + return _monitors.AsReadOnly(); + } + finally + { + _discoveryLock.Release(); + } + } + + /// + /// Discover monitors from all registered controllers in parallel. + /// + private async Task> DiscoverFromAllControllersAsync(CancellationToken cancellationToken) + { + var tasks = new List>>(); + + if (_ddcController != null) + { + tasks.Add(SafeDiscoverAsync(_ddcController, cancellationToken)); + } + + if (_wmiController != null) + { + tasks.Add(SafeDiscoverAsync(_wmiController, cancellationToken)); + } + + var results = await Task.WhenAll(tasks); + return results.SelectMany(m => m).ToList(); + } + + /// + /// Safely discover monitors from a controller, returning empty list on failure. + /// + private static async Task> SafeDiscoverAsync( + IMonitorController controller, + CancellationToken cancellationToken) + { + try + { + return await controller.DiscoverMonitorsAsync(cancellationToken); + } + catch (Exception ex) + { + Logger.LogWarning($"Controller {controller.Name} discovery failed: {ex.Message}"); + return Enumerable.Empty(); + } + } + + /// + /// Get brightness of the specified monitor + /// + public async Task GetBrightnessAsync(string monitorId, CancellationToken cancellationToken = default) + { + var monitor = GetMonitor(monitorId); + if (monitor == null) + { + return VcpFeatureValue.Invalid; + } + + var controller = GetControllerForMonitor(monitor); + if (controller == null) + { + return VcpFeatureValue.Invalid; + } + + try + { + var brightnessInfo = await controller.GetBrightnessAsync(monitor, cancellationToken); + + // Update cached brightness value + if (brightnessInfo.IsValid) + { + monitor.UpdateStatus(brightnessInfo.ToPercentage(), true); + } + + return brightnessInfo; + } + catch (Exception ex) + { + // Mark monitor as unavailable + Logger.LogError($"Failed to get brightness for monitor {monitorId}: {ex.Message}"); + monitor.IsAvailable = false; + return VcpFeatureValue.Invalid; + } + } + + /// + /// Set brightness of the specified monitor + /// + public Task SetBrightnessAsync(string monitorId, int brightness, CancellationToken cancellationToken = default) + => ExecuteMonitorOperationAsync( + monitorId, + brightness, + (ctrl, mon, val, ct) => ctrl.SetBrightnessAsync(mon, val, ct), + (mon, val) => mon.UpdateStatus(val, true), + cancellationToken); + + /// + /// Set contrast of the specified monitor + /// + public Task SetContrastAsync(string monitorId, int contrast, CancellationToken cancellationToken = default) + => ExecuteMonitorOperationAsync( + monitorId, + contrast, + (ctrl, mon, val, ct) => ctrl.SetContrastAsync(mon, val, ct), + (mon, val) => mon.CurrentContrast = val, + cancellationToken); + + /// + /// Set volume of the specified monitor + /// + public Task SetVolumeAsync(string monitorId, int volume, CancellationToken cancellationToken = default) + => ExecuteMonitorOperationAsync( + monitorId, + volume, + (ctrl, mon, val, ct) => ctrl.SetVolumeAsync(mon, val, ct), + (mon, val) => mon.CurrentVolume = val, + cancellationToken); + + /// + /// Get monitor color temperature + /// + public async Task GetColorTemperatureAsync(string monitorId, CancellationToken cancellationToken = default) + { + var monitor = GetMonitor(monitorId); + if (monitor == null) + { + return VcpFeatureValue.Invalid; + } + + var controller = GetControllerForMonitor(monitor); + if (controller == null) + { + return VcpFeatureValue.Invalid; + } + + try + { + return await controller.GetColorTemperatureAsync(monitor, cancellationToken); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + return VcpFeatureValue.Invalid; + } + } + + /// + /// Set monitor color temperature + /// + public Task SetColorTemperatureAsync(string monitorId, int colorTemperature, CancellationToken cancellationToken = default) + => ExecuteMonitorOperationAsync( + monitorId, + colorTemperature, + (ctrl, mon, val, ct) => ctrl.SetColorTemperatureAsync(mon, val, ct), + (mon, val) => mon.CurrentColorTemperature = val, + cancellationToken); + + /// + /// Get current input source for a monitor + /// + public async Task GetInputSourceAsync(string monitorId, CancellationToken cancellationToken = default) + { + var monitor = GetMonitor(monitorId); + if (monitor == null) + { + return VcpFeatureValue.Invalid; + } + + var controller = GetControllerForMonitor(monitor); + if (controller == null) + { + return VcpFeatureValue.Invalid; + } + + try + { + return await controller.GetInputSourceAsync(monitor, cancellationToken); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + return VcpFeatureValue.Invalid; + } + } + + /// + /// Set input source for a monitor + /// + public Task SetInputSourceAsync(string monitorId, int inputSource, CancellationToken cancellationToken = default) + => ExecuteMonitorOperationAsync( + monitorId, + inputSource, + (ctrl, mon, val, ct) => ctrl.SetInputSourceAsync(mon, val, ct), + (mon, val) => mon.CurrentInputSource = val, + cancellationToken); + + /// + /// Set power state for a monitor using VCP 0xD6. + /// Note: Setting any state other than On (0x01) will turn off the display. + /// We don't update monitor state since the display will be off. + /// + public Task SetPowerStateAsync(string monitorId, int powerState, CancellationToken cancellationToken = default) + => ExecuteMonitorOperationAsync( + monitorId, + powerState, + (ctrl, mon, val, ct) => ctrl.SetPowerStateAsync(mon, val, ct), + (mon, val) => { }, // No state update - display will be off for non-On values + cancellationToken); + + /// + /// Set rotation/orientation for a monitor. + /// Uses Windows ChangeDisplaySettingsEx API (not DDC/CI). + /// After successful rotation, refreshes orientation for all monitors sharing the same GdiDeviceName + /// (important for mirror/clone mode where multiple monitors share one display source). + /// + /// Monitor ID + /// Orientation: 0=normal, 1=90°, 2=180°, 3=270° + /// Cancellation token + /// Operation result + public Task 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")); + } + + // Rotation uses Windows display settings API, not DDC/CI controller + // Prefer using Monitor object which contains GdiDeviceName for accurate adapter targeting + var result = _rotationService.SetRotation(monitor, orientation); + + if (result.IsSuccess) + { + // Refresh orientation for all monitors - rotation affects the GdiDeviceName (display source), + // and in mirror mode multiple monitors may share the same GdiDeviceName + RefreshAllOrientations(); + } + else + { + Logger.LogError($"[MonitorManager] SetRotation: Failed for {monitorId}: {result.ErrorMessage}"); + } + + return Task.FromResult(result); + } + + /// + /// Refresh orientation values for all monitors by querying current display settings. + /// This ensures all monitors reflect the actual system state, which is important + /// in mirror mode where multiple monitors share the same GdiDeviceName. + /// + public void RefreshAllOrientations() + { + foreach (var monitor in _monitors) + { + if (string.IsNullOrEmpty(monitor.GdiDeviceName)) + { + continue; + } + + var currentOrientation = _rotationService.GetCurrentOrientation(monitor.GdiDeviceName); + if (currentOrientation >= 0 && currentOrientation != monitor.Orientation) + { + monitor.Orientation = currentOrientation; + monitor.LastUpdate = DateTime.Now; + } + } + } + + /// + /// Get monitor by ID. Uses dictionary lookup for O(1) performance. + /// + public Monitor? GetMonitor(string monitorId) + { + return _monitorLookup.TryGetValue(monitorId, out var monitor) ? monitor : null; + } + + /// + /// Get controller for the monitor based on CommunicationMethod. + /// O(1) lookup - no async validation needed since controller type is determined at discovery. + /// + private IMonitorController? GetControllerForMonitor(Monitor monitor) + { + return monitor.CommunicationMethod switch + { + "WMI" => _wmiController, + "DDC/CI" => _ddcController, + _ => null, + }; + } + + /// + /// Generic helper to execute monitor operations with common error handling. + /// Eliminates code duplication across Set* methods. + /// + private async Task ExecuteMonitorOperationAsync( + string monitorId, + T value, + Func> operation, + Action onSuccess, + CancellationToken cancellationToken = default) + { + var monitor = GetMonitor(monitorId); + if (monitor == null) + { + Logger.LogError($"[MonitorManager] Monitor not found: {monitorId}"); + return MonitorOperationResult.Failure("Monitor not found"); + } + + var controller = GetControllerForMonitor(monitor); + if (controller == null) + { + Logger.LogError($"[MonitorManager] No controller available for monitor {monitorId}"); + return MonitorOperationResult.Failure("No controller available for this monitor"); + } + + try + { + var result = await operation(controller, monitor, value, cancellationToken); + + if (result.IsSuccess) + { + onSuccess(monitor, value); + monitor.LastUpdate = DateTime.Now; + } + else + { + monitor.IsAvailable = false; + } + + return result; + } + catch (Exception ex) + { + monitor.IsAvailable = false; + Logger.LogError($"[MonitorManager] Operation failed for {monitorId}: {ex.Message}"); + return MonitorOperationResult.Failure($"Exception: {ex.Message}"); + } + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (!_disposed && disposing) + { + _discoveryLock?.Dispose(); + + // Release controllers + _ddcController?.Dispose(); + _wmiController?.Dispose(); + + _monitors.Clear(); + _monitorLookup.Clear(); + _disposed = true; + } + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay/Helpers/NamedPipeProcessor.cs b/src/modules/powerdisplay/PowerDisplay/Helpers/NamedPipeProcessor.cs new file mode 100644 index 0000000000..9945ad9da9 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/Helpers/NamedPipeProcessor.cs @@ -0,0 +1,74 @@ +// 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.IO; +using System.IO.Pipes; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using ManagedCommon; + +namespace PowerDisplay.Helpers; + +/// +/// Processes messages from the Module DLL via Named Pipe. +/// Based on AdvancedPaste NamedPipeProcessor pattern. +/// +public static class NamedPipeProcessor +{ + /// + /// Connects to a named pipe and processes incoming messages. + /// This method runs continuously until cancelled or the pipe is disconnected. + /// + /// The name of the pipe to connect to. + /// Timeout for initial connection. + /// Handler for each received message. + /// Token to cancel the operation. + public static async Task ProcessNamedPipeAsync( + string pipeName, + TimeSpan connectTimeout, + Action messageHandler, + CancellationToken cancellationToken) + { + try + { + using NamedPipeClientStream pipeClient = new(".", pipeName, PipeDirection.In); + + Logger.LogInfo($"[NamedPipe] Connecting to pipe: {pipeName}"); + await pipeClient.ConnectAsync(connectTimeout, cancellationToken); + Logger.LogInfo($"[NamedPipe] Connected to pipe: {pipeName}"); + + using StreamReader streamReader = new(pipeClient, Encoding.Unicode); + + while (!cancellationToken.IsCancellationRequested) + { + var message = await streamReader.ReadLineAsync(cancellationToken); + + if (message != null) + { + Logger.LogInfo($"[NamedPipe] Received message: {message}"); + messageHandler(message); + } + + // Small delay to prevent tight loop + var intraMessageDelay = TimeSpan.FromMilliseconds(10); + await Task.Delay(intraMessageDelay, cancellationToken); + } + } + catch (OperationCanceledException) + { + Logger.LogInfo("[NamedPipe] Processing cancelled"); + } + catch (IOException ex) + { + // Pipe disconnected, this is expected when the module DLL terminates + Logger.LogInfo($"[NamedPipe] Pipe disconnected: {ex.Message}"); + } + catch (Exception ex) + { + Logger.LogError($"[NamedPipe] Error processing pipe: {ex.Message}"); + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay/Helpers/NativeEventWaiter.cs b/src/modules/powerdisplay/PowerDisplay/Helpers/NativeEventWaiter.cs new file mode 100644 index 0000000000..33ef13664c --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/Helpers/NativeEventWaiter.cs @@ -0,0 +1,89 @@ +// 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.Threading; +using ManagedCommon; +using Microsoft.UI.Dispatching; + +namespace PowerDisplay.Helpers +{ + /// + /// Helper class for waiting on Windows Named Events (Awake pattern) + /// Based on Peek.UI implementation + /// + public static class NativeEventWaiter + { + /// + /// Wait for a Windows Event in a background thread and invoke callback on UI thread when signaled + /// + /// Name of the Windows Event to wait for + /// Callback to invoke when event is signaled + /// Token to cancel the wait loop + public static void WaitForEventLoop(string eventName, Action callback, CancellationToken cancellationToken) + { + Logger.LogTrace($"[NativeEventWaiter] Setting up event loop for event: {eventName}"); + var dispatcherQueue = DispatcherQueue.GetForCurrentThread(); + + if (dispatcherQueue == null) + { + Logger.LogError($"[NativeEventWaiter] DispatcherQueue is null for event: {eventName}"); + return; + } + + Logger.LogTrace($"[NativeEventWaiter] DispatcherQueue obtained for event: {eventName}"); + + var t = new Thread(() => + { + Logger.LogInfo($"[NativeEventWaiter] Background thread started for event: {eventName}"); + try + { + Logger.LogTrace($"[NativeEventWaiter] Creating EventWaitHandle for event: {eventName}"); + using var eventHandle = new EventWaitHandle(false, EventResetMode.AutoReset, eventName); + Logger.LogInfo($"[NativeEventWaiter] EventWaitHandle created successfully for event: {eventName}"); + + int waitCount = 0; + while (!cancellationToken.IsCancellationRequested) + { + // Use 500ms timeout for polling + if (eventHandle.WaitOne(500)) + { + waitCount++; + Logger.LogInfo($"[NativeEventWaiter] Event SIGNALED: {eventName} (signal count: {waitCount})"); + bool enqueued = dispatcherQueue.TryEnqueue(() => + { + Logger.LogTrace($"[NativeEventWaiter] Executing callback on UI thread for event: {eventName}"); + try + { + callback(); + Logger.LogTrace($"[NativeEventWaiter] Callback completed for event: {eventName}"); + } + catch (Exception callbackEx) + { + Logger.LogError($"[NativeEventWaiter] Callback exception for event {eventName}: {callbackEx.Message}"); + } + }); + + if (!enqueued) + { + Logger.LogError($"[NativeEventWaiter] Failed to enqueue callback to UI thread for event: {eventName}"); + } + } + } + + Logger.LogInfo($"[NativeEventWaiter] Event loop ending for event: {eventName} (cancellation requested)"); + } + catch (Exception ex) + { + Logger.LogError($"[NativeEventWaiter] Exception in event loop for {eventName}: {ex.Message}\n{ex.StackTrace}"); + } + }); + + t.IsBackground = true; + t.Name = $"NativeEventWaiter_{eventName}"; + t.Start(); + Logger.LogTrace($"[NativeEventWaiter] Background thread started with name: {t.Name}"); + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay/Helpers/ResourceLoaderInstance.cs b/src/modules/powerdisplay/PowerDisplay/Helpers/ResourceLoaderInstance.cs new file mode 100644 index 0000000000..cb549336fd --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/Helpers/ResourceLoaderInstance.cs @@ -0,0 +1,18 @@ +// 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 Microsoft.Windows.ApplicationModel.Resources; + +namespace PowerDisplay.Helpers +{ + public static class ResourceLoaderInstance + { + public static ResourceLoader ResourceLoader { get; private set; } + + static ResourceLoaderInstance() + { + ResourceLoader = new ResourceLoader("PowerToys.PowerDisplay.pri"); + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay/Helpers/SettingsDeepLink.cs b/src/modules/powerdisplay/PowerDisplay/Helpers/SettingsDeepLink.cs new file mode 100644 index 0000000000..d721fa5811 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/Helpers/SettingsDeepLink.cs @@ -0,0 +1,37 @@ +// 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.Diagnostics; +using System.IO; + +namespace PowerDisplay.Helpers +{ + public static class SettingsDeepLink + { + public static void OpenSettings(bool mainExecutableIsOnTheParentFolder) + { + try + { + var directoryPath = System.AppContext.BaseDirectory; + if (mainExecutableIsOnTheParentFolder) + { + // Need to go into parent folder for PowerToys.exe. Likely a WinUI3 App SDK application. + directoryPath = Path.Combine(directoryPath, ".."); + directoryPath = Path.Combine(directoryPath, "PowerToys.exe"); + } + else + { + // PowerToys.exe is in the same path as the application. + directoryPath = Path.Combine(directoryPath, "PowerToys.exe"); + } + + Process.Start(new ProcessStartInfo(directoryPath) { Arguments = "--open-settings=PowerDisplay" }); + } + catch + { + // Silently ignore errors opening settings + } + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay/Helpers/TrayIconService.cs b/src/modules/powerdisplay/PowerDisplay/Helpers/TrayIconService.cs new file mode 100644 index 0000000000..ab8dca6b80 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/Helpers/TrayIconService.cs @@ -0,0 +1,319 @@ +// 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.Diagnostics.CodeAnalysis; +using System.IO; +using System.Runtime.InteropServices; +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.UI.Xaml; +using Windows.Win32; +using Windows.Win32.Foundation; +using Windows.Win32.UI.Shell; +using Windows.Win32.UI.WindowsAndMessaging; +using WinRT.Interop; + +namespace PowerDisplay.Helpers +{ + /// + /// Window procedure delegate for handling window messages. + /// Uses primitive types to avoid accessibility issues with CsWin32-generated types. + /// + /// Handle to the window. + /// The message. + /// Additional message information. + /// Additional message. + /// The result of the message processing. + internal delegate nint WndProcDelegate(nint hwnd, uint msg, nuint wParam, nint lParam); + + internal sealed partial class TrayIconService + { + private const uint MyNotifyId = 1001; + private const uint WmTrayIcon = PInvoke.WM_USER + 1; + + private readonly SettingsUtils _settingsUtils; + private readonly Action _toggleWindowAction; + private readonly Action _exitAction; + private readonly Action _openSettingsAction; + private readonly uint _wmTaskbarRestart; + + private Window? _window; + private nint _hwnd; + private nint _originalWndProc; + private WndProcDelegate? _trayWndProc; + private NOTIFYICONDATAW? _trayIconData; + private nint _largeIcon; + private nint _popupMenu; + + public TrayIconService( + SettingsUtils settingsUtils, + Action toggleWindowAction, + Action exitAction, + Action openSettingsAction) + { + _settingsUtils = settingsUtils; + _toggleWindowAction = toggleWindowAction; + _exitAction = exitAction; + _openSettingsAction = openSettingsAction; + + // TaskbarCreated is the message that's broadcast when explorer.exe + // restarts. We need to know when that happens to be able to bring our + // notification area icon back + _wmTaskbarRestart = RegisterWindowMessageNative("TaskbarCreated"); + } + + public void SetupTrayIcon(bool? showSystemTrayIcon = null) + { + var settings = _settingsUtils.GetSettingsOrDefault(PowerDisplaySettings.ModuleName); + bool shouldShow = showSystemTrayIcon ?? settings.Properties.ShowSystemTrayIcon; + + if (shouldShow) + { + if (_window is null) + { + _window = new Window(); + _hwnd = WindowNative.GetWindowHandle(_window); + + // LOAD BEARING: If you don't stick the pointer to HotKeyPrc into a + // member (and instead like, use a local), then the pointer we marshal + // into the WindowLongPtr will be useless after we leave this function, + // and our **WindProc will explode**. + _trayWndProc = WindowProc; + var hotKeyPrcPointer = Marshal.GetFunctionPointerForDelegate(_trayWndProc); + _originalWndProc = SetWindowLongPtrNative(_hwnd, GwlWndproc, hotKeyPrcPointer); + } + + if (_trayIconData is null) + { + // We need to stash this handle, so it doesn't clean itself up. If + // explorer restarts, we'll come back through here, and we don't + // really need to re-load the icon in that case. We can just use + // the handle from the first time. + _largeIcon = GetAppIconHandle(); + unsafe + { + _trayIconData = new NOTIFYICONDATAW() + { + cbSize = (uint)sizeof(NOTIFYICONDATAW), + hWnd = new HWND(_hwnd), + uID = MyNotifyId, + uFlags = NOTIFY_ICON_DATA_FLAGS.NIF_MESSAGE | NOTIFY_ICON_DATA_FLAGS.NIF_ICON | NOTIFY_ICON_DATA_FLAGS.NIF_TIP, + uCallbackMessage = WmTrayIcon, + hIcon = new HICON(_largeIcon), + szTip = GetString("AppName"), + }; + } + } + + var d = (NOTIFYICONDATAW)_trayIconData; + + // Add the notification icon + unsafe + { + bool success = Shell_NotifyIconNative((uint)NOTIFY_ICON_MESSAGE.NIM_ADD, &d); + if (!success) + { + // Shell_NotifyIcon can fail if explorer.exe isn't ready yet (e.g., during system startup) + // Reset _trayIconData to allow retry via WM_WINDOWPOSCHANGING or WM_TASKBAR_RESTART + Logger.LogWarning("[TrayIcon] Shell_NotifyIcon(NIM_ADD) failed, will retry later"); + _trayIconData = null; + return; + } + } + + if (_popupMenu == 0) + { + _popupMenu = CreatePopupMenu(); + InsertMenuNative(_popupMenu, 0, (uint)(MENU_ITEM_FLAGS.MF_BYPOSITION | MENU_ITEM_FLAGS.MF_STRING), PInvoke.WM_USER + 1, GetString("TrayMenu_Settings")); + InsertMenuNative(_popupMenu, 1, (uint)(MENU_ITEM_FLAGS.MF_BYPOSITION | MENU_ITEM_FLAGS.MF_STRING), PInvoke.WM_USER + 2, GetString("TrayMenu_Exit")); + } + } + else + { + Destroy(); + } + } + + public void Destroy() + { + if (_trayIconData is not null) + { + var d = (NOTIFYICONDATAW)_trayIconData; + unsafe + { + if (Shell_NotifyIconNative((uint)NOTIFY_ICON_MESSAGE.NIM_DELETE, &d)) + { + _trayIconData = null; + } + } + } + + if (_popupMenu != 0) + { + DestroyMenu(_popupMenu); + _popupMenu = 0; + } + + if (_largeIcon != 0) + { + DestroyIcon(_largeIcon); + _largeIcon = 0; + } + + if (_window is not null) + { + _window.Close(); + _window = null; + _hwnd = 0; + } + } + + private static string GetString(string key) + { + try + { + return ResourceLoaderInstance.ResourceLoader.GetString(key); + } + catch + { + return "unknown"; + } + } + + private nint GetAppIconHandle() + { + var exePath = Path.Combine(AppContext.BaseDirectory, "PowerToys.PowerDisplay.exe"); + ExtractIconExNative(exePath, 0, out var largeIcon, out _, 1); + return largeIcon; + } + + private nint WindowProc( + nint hwnd, + uint uMsg, + nuint wParam, + nint lParam) + { + switch (uMsg) + { + case PInvoke.WM_COMMAND: + { + if (wParam == PInvoke.WM_USER + 1) + { + // Settings menu item + _openSettingsAction?.Invoke(); + } + else if (wParam == PInvoke.WM_USER + 2) + { + // Exit menu item + _exitAction?.Invoke(); + } + } + + break; + + // Shell_NotifyIcon can fail when we invoke it during the time explorer.exe isn't present/ready to handle it. + // We'll also never receive _wmTaskbarRestart message if the first call to Shell_NotifyIcon failed, so we use + // WM_WINDOWPOSCHANGING which is always received on explorer startup sequence. + case PInvoke.WM_WINDOWPOSCHANGING: + { + if (_trayIconData is null) + { + SetupTrayIcon(); + } + } + + break; + default: + // _wmTaskbarRestart isn't a compile-time constant, so we can't + // use it in a case label + if (uMsg == _wmTaskbarRestart) + { + // Handle the case where explorer.exe restarts. + // Even if we created it before, do it again + SetupTrayIcon(); + } + else if (uMsg == WmTrayIcon) + { + switch ((uint)lParam) + { + case PInvoke.WM_RBUTTONUP: + { + if (_popupMenu != 0) + { + GetCursorPos(out var cursorPos); + SetForegroundWindow(_hwnd); + TrackPopupMenuExNative(_popupMenu, (uint)TRACK_POPUP_MENU_FLAGS.TPM_LEFTALIGN | (uint)TRACK_POPUP_MENU_FLAGS.TPM_BOTTOMALIGN, cursorPos.X, cursorPos.Y, _hwnd, 0); + } + } + + break; + case PInvoke.WM_LBUTTONUP: + case PInvoke.WM_LBUTTONDBLCLK: + _toggleWindowAction?.Invoke(); + break; + } + } + + break; + } + + return CallWindowProcIntPtr(_originalWndProc, hwnd, uMsg, wParam, lParam); + } + + [LibraryImport("user32.dll", EntryPoint = "CallWindowProcW")] + private static partial nint CallWindowProcIntPtr(IntPtr lpPrevWndFunc, nint hWnd, uint msg, nuint wParam, nint lParam); + + [LibraryImport("user32.dll", EntryPoint = "RegisterWindowMessageW", StringMarshalling = StringMarshalling.Utf16)] + private static partial uint RegisterWindowMessageNative(string lpString); + + [LibraryImport("user32.dll", EntryPoint = "SetWindowLongPtrW")] + private static partial nint SetWindowLongPtrNative(nint hWnd, int nIndex, nint dwNewLong); + + [LibraryImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool GetCursorPos(out POINT lpPoint); + + [LibraryImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool SetForegroundWindow(nint hWnd); + + // Shell APIs - use uint for enums and unsafe pointer for struct + [LibraryImport("shell32.dll", EntryPoint = "Shell_NotifyIconW")] + [return: MarshalAs(UnmanagedType.Bool)] + private static unsafe partial bool Shell_NotifyIconNative(uint dwMessage, NOTIFYICONDATAW* lpData); + + [LibraryImport("shell32.dll", EntryPoint = "ExtractIconExW", StringMarshalling = StringMarshalling.Utf16)] + private static partial uint ExtractIconExNative(string lpszFile, int nIconIndex, out nint phiconLarge, out nint phiconSmall, uint nIcons); + + // Menu APIs + [LibraryImport("user32.dll")] + private static partial nint CreatePopupMenu(); + + [LibraryImport("user32.dll", EntryPoint = "InsertMenuW", StringMarshalling = StringMarshalling.Utf16)] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool InsertMenuNative(nint hMenu, uint uPosition, uint uFlags, nuint uIDNewItem, string? lpNewItem); + + [LibraryImport("user32.dll", EntryPoint = "TrackPopupMenuEx")] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool TrackPopupMenuExNative(nint hMenu, uint uFlags, int x, int y, nint hwnd, nint lptpm); + + [LibraryImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool DestroyMenu(nint hMenu); + + [LibraryImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool DestroyIcon(nint hIcon); + + [StructLayout(LayoutKind.Sequential)] + private struct POINT + { + public int X; + public int Y; + } + + private const int GwlWndproc = -4; + } +} diff --git a/src/modules/powerdisplay/PowerDisplay/Helpers/TypePreservation.cs b/src/modules/powerdisplay/PowerDisplay/Helpers/TypePreservation.cs new file mode 100644 index 0000000000..6ae3be94dc --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/Helpers/TypePreservation.cs @@ -0,0 +1,81 @@ +// 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.Diagnostics.CodeAnalysis; + +namespace PowerDisplay.Helpers +{ + /// + /// This class ensures types used in XAML are preserved during AOT compilation. + /// Framework types cannot have attributes added directly to their definitions since they're external types. + /// Use DynamicDependency to preserve all members of these WinUI3 framework types. + /// + internal static class TypePreservation + { + // Core WinUI3 Controls used in XAML + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.Window))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.Application))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.Controls.Grid))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.Controls.Border))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.Controls.ScrollViewer))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.Controls.StackPanel))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.Controls.ItemsControl))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.Controls.Slider))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.Controls.TextBlock))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.Controls.Button))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.Controls.FontIcon))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.Controls.ProgressRing))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.Controls.InfoBar))] + + // Animation and Transform types + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.Media.Animation.Storyboard))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.Media.Animation.DoubleAnimation))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.Media.Animation.CubicEase))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.Media.TranslateTransform))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.Media.Animation.TransitionCollection))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.Media.Animation.EntranceThemeTransition))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.Media.Animation.RepositionThemeTransition))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.Media.Animation.EasingFunctionBase))] + + // Template and Resource types + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.DataTemplate))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.Controls.ItemsPanelTemplate))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.Style))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.Controls.FontIconSource))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.ResourceDictionary))] + + // Text and Document types + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.Documents.Run))] + + // Layout types + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.Controls.RowDefinition))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.Controls.ColumnDefinition))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.Controls.RowDefinitionCollection))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.Controls.ColumnDefinitionCollection))] + + // Media types for brushes and visuals + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.Media.SolidColorBrush))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.Media.Brush))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.Media.Transform))] + + // Core UI element types + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.UIElement))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.FrameworkElement))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.DependencyObject))] + + // Thickness and other value types used in XAML (structs, not enums) + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.Thickness))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.CornerRadius))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.GridLength))] + + // ToolTip service used in buttons + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.Controls.ToolTipService))] + + public static void PreserveTypes() + { + // This method exists only to hold the DynamicDependency attributes above. + // It must be called to ensure the types are not trimmed during AOT compilation. + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay/Helpers/VisibilityConverter.cs b/src/modules/powerdisplay/PowerDisplay/Helpers/VisibilityConverter.cs new file mode 100644 index 0000000000..ef3314f440 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/Helpers/VisibilityConverter.cs @@ -0,0 +1,22 @@ +// 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 Microsoft.UI.Xaml; + +namespace PowerDisplay.Helpers +{ + /// + /// Provides conversion utilities for Visibility binding in x:Bind scenarios. + /// AOT-compatible alternative to IValueConverter implementations. + /// + public static class VisibilityConverter + { + /// + /// Converts a boolean value to a Visibility value. + /// + /// The boolean value to convert. + /// Visibility.Visible if true, Visibility.Collapsed if false. + public static Visibility BoolToVisibility(bool value) => value ? Visibility.Visible : Visibility.Collapsed; + } +} diff --git a/src/modules/powerdisplay/PowerDisplay/Helpers/WindowHelper.cs b/src/modules/powerdisplay/PowerDisplay/Helpers/WindowHelper.cs new file mode 100644 index 0000000000..b5ae7a391f --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/Helpers/WindowHelper.cs @@ -0,0 +1,261 @@ +// 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.Runtime.InteropServices; +using Microsoft.UI; +using Microsoft.UI.Windowing; +using WinUIEx; + +namespace PowerDisplay.Helpers +{ + internal static partial class WindowHelper + { + // Cursor position structure for GetCursorPos + [StructLayout(LayoutKind.Sequential)] + private struct POINT + { + public int X; + public int Y; + } + + // Cursor position for detecting the monitor with the mouse + [LibraryImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool GetCursorPos(out POINT lpPoint); + + // Window Styles + private const int GwlStyle = -16; + private const int WsCaption = 0x00C00000; + private const int WsThickframe = 0x00040000; + private const int WsMinimizebox = 0x00020000; + private const int WsMaximizebox = 0x00010000; + private const int WsSysmenu = 0x00080000; + + // Extended Window Styles + private const int GwlExstyle = -20; + private const int WsExDlgmodalframe = 0x00000001; + private const int WsExWindowedge = 0x00000100; + private const int WsExClientedge = 0x00000200; + private const int WsExStaticedge = 0x00020000; + private const int WsExToolwindow = 0x00000080; + + private const uint SwpNosize = 0x0001; + private const uint SwpNomove = 0x0002; + private const uint SwpFramechanged = 0x0020; + private const nint HwndTopmost = -1; + private const nint HwndNotopmost = -2; + + // ShowWindow commands + private const int SwHide = 0; + private const int SwShow = 5; + + // P/Invoke declarations (64-bit only - PowerToys only builds for x64/ARM64) + [LibraryImport("user32.dll", EntryPoint = "GetWindowLongPtrW")] + private static partial nint GetWindowLongPtr(nint hWnd, int nIndex); + + [LibraryImport("user32.dll", EntryPoint = "SetWindowLongPtrW")] + private static partial nint SetWindowLong(nint hWnd, int nIndex, nint dwNewLong); + + [LibraryImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool SetWindowPos( + nint hWnd, + nint hWndInsertAfter, + int x, + int y, + int cx, + int cy, + uint uFlags); + + [LibraryImport("user32.dll", EntryPoint = "ShowWindow")] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool ShowWindowNative(nint hWnd, int nCmdShow); + + [LibraryImport("user32.dll", EntryPoint = "IsWindowVisible")] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool IsWindowVisibleNative(nint hWnd); + + /// + /// Check if window is visible + /// + public static bool IsWindowVisible(nint hWnd) + { + return IsWindowVisibleNative(hWnd); + } + + /// + /// Disable window moving and resizing functionality + /// + public static void DisableWindowMovingAndResizing(nint hWnd) + { + // Get current window style + nint style = GetWindowLongPtr(hWnd, GwlStyle); + + // Remove resizable borders, title bar, and system menu + style &= ~WsThickframe; + style &= ~WsMaximizebox; + style &= ~WsMinimizebox; + style &= ~WsCaption; // Remove entire title bar + style &= ~WsSysmenu; // Remove system menu + + // Set new window style + _ = SetWindowLong(hWnd, GwlStyle, style); + + // Get extended style and remove related borders + nint exStyle = GetWindowLongPtr(hWnd, GwlExstyle); + exStyle &= ~WsExDlgmodalframe; + exStyle &= ~WsExWindowedge; + exStyle &= ~WsExClientedge; + exStyle &= ~WsExStaticedge; + _ = SetWindowLong(hWnd, GwlExstyle, exStyle); + + // Refresh window frame + SetWindowPos( + hWnd, + 0, + 0, + 0, + 0, + 0, + SwpNomove | SwpNosize | SwpFramechanged); + } + + /// + /// Set whether window is topmost + /// + public static void SetWindowTopmost(nint hWnd, bool topmost) + { + SetWindowPos( + hWnd, + topmost ? HwndTopmost : HwndNotopmost, + 0, + 0, + 0, + 0, + SwpNomove | SwpNosize); + } + + /// + /// Show or hide window + /// + public static void ShowWindow(nint hWnd, bool show) + { + ShowWindowNative(hWnd, show ? SwShow : SwHide); + } + + /// + /// Hide window from taskbar + /// + public static void HideFromTaskbar(nint hWnd) + { + // Get current extended style + nint exStyle = GetWindowLongPtr(hWnd, GwlExstyle); + + // Add WS_EX_TOOLWINDOW style to hide window from taskbar + exStyle |= WsExToolwindow; + + // Set new extended style + _ = SetWindowLong(hWnd, GwlExstyle, exStyle); + + // Refresh window frame + SetWindowPos( + hWnd, + 0, + 0, + 0, + 0, + 0, + SwpNomove | SwpNosize | SwpFramechanged); + } + + /// + /// Get the DPI scale factor for a window (relative to standard 96 DPI) + /// + /// WinUIEx window + /// DPI scale factor (1.0 = 100%, 1.25 = 125%, 1.5 = 150%, 2.0 = 200%) + public static double GetDpiScale(WindowEx window) + { + return (float)window.GetDpiForWindow() / 96.0; + } + + /// + /// Convert device-independent units (DIU) to physical pixels + /// + /// Device-independent unit value + /// DPI scale factor + /// Physical pixel value + public static int ScaleToPhysicalPixels(int diu, double dpiScale) + { + return (int)Math.Ceiling(diu * dpiScale); + } + + /// + /// Position a window at the bottom-right corner of the monitor where the mouse cursor is located. + /// Correctly handles all edge cases: + /// - Multi-monitor setups + /// - Taskbar at any position (top/bottom/left/right) + /// - Different DPI settings + /// + /// WinUIEx window to position + /// Window width in device-independent units (DIU) + /// Window height in device-independent units (DIU) + /// Right margin in device-independent units (DIU) + public static void PositionWindowBottomRight( + WindowEx window, + int width, + int height, + int rightMargin = 0) + { + // RectWork already includes correct offsets for taskbar position + var monitors = MonitorInfo.GetDisplayMonitors(); + if (monitors == null || monitors.Count == 0) + { + ManagedCommon.Logger.LogWarning("PositionWindowBottomRight: No monitors found, skipping positioning"); + return; + } + + // Find the monitor where the mouse cursor is located + var targetMonitor = GetMonitorAtCursor(monitors); + var workArea = targetMonitor.RectWork; + double dpiScale = GetDpiScale(window); + + // Calculate bottom-right position + // RectWork.Right/Bottom already account for taskbar position + double x = workArea.Right - (dpiScale * (width + rightMargin)); + double y = workArea.Bottom - (dpiScale * height); + + window.MoveAndResize(x, y, width, height); + } + + /// + /// Get the monitor where the mouse cursor is currently located. + /// Falls back to primary monitor if cursor position cannot be determined. + /// + /// List of available monitors + /// MonitorInfo of the monitor containing the cursor + private static MonitorInfo GetMonitorAtCursor(IList monitors) + { + // Try to get cursor position using Win32 API + if (GetCursorPos(out var cursorPos)) + { + // Find the monitor that contains the cursor point + foreach (var monitor in monitors) + { + if (cursorPos.X >= monitor.RectMonitor.Left && + cursorPos.X < monitor.RectMonitor.Right && + cursorPos.Y >= monitor.RectMonitor.Top && + cursorPos.Y < monitor.RectMonitor.Bottom) + { + return monitor; + } + } + } + + // Fallback to first monitor (typically primary) + return monitors[0]; + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay/NativeMethods.json b/src/modules/powerdisplay/PowerDisplay/NativeMethods.json new file mode 100644 index 0000000000..450ecacafd --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/NativeMethods.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://aka.ms/CsWin32.schema.json", + "public": true, + "allowMarshaling": false +} diff --git a/src/modules/powerdisplay/PowerDisplay/NativeMethods.txt b/src/modules/powerdisplay/PowerDisplay/NativeMethods.txt new file mode 100644 index 0000000000..754b73be27 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/NativeMethods.txt @@ -0,0 +1,17 @@ +// Structs and types only - functions use LibraryImport for AOT compatibility +NOTIFYICONDATAW +NOTIFY_ICON_MESSAGE +NOTIFY_ICON_DATA_FLAGS +MENU_ITEM_FLAGS +TRACK_POPUP_MENU_FLAGS + +// Window message constants (used by TrayIconService) +WM_USER +WM_COMMAND +WM_RBUTTONUP +WM_LBUTTONUP +WM_LBUTTONDBLCLK +WM_WINDOWPOSCHANGING + +// COM wait flags for single instance redirection (constants only) +CWMO_FLAGS diff --git a/src/modules/powerdisplay/PowerDisplay/PowerDisplay.csproj b/src/modules/powerdisplay/PowerDisplay/PowerDisplay.csproj new file mode 100644 index 0000000000..78107c261b --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/PowerDisplay.csproj @@ -0,0 +1,104 @@ + + + + + + + WinExe + PowerDisplay + app.manifest + Assets\PowerDisplay\PowerDisplay.ico + x64;ARM64 + true + true + true + None + false + false + true + ..\..\..\..\$(Platform)\$(Configuration)\WinUI3Apps + PowerToys.PowerDisplay + + PowerToys.PowerDisplay.pri + true + enable + + DISABLE_XAML_GENERATED_MAIN + + + + false + false + true + false + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + all + + + + + + + + + + + + + + + + + + + PreserveNewest + + + + + true + + \ No newline at end of file diff --git a/src/modules/powerdisplay/PowerDisplay/PowerDisplayXAML/App.xaml b/src/modules/powerdisplay/PowerDisplay/PowerDisplayXAML/App.xaml new file mode 100644 index 0000000000..9822ec0c29 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/PowerDisplayXAML/App.xaml @@ -0,0 +1,15 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/src/modules/powerdisplay/PowerDisplay/PowerDisplayXAML/App.xaml.cs b/src/modules/powerdisplay/PowerDisplay/PowerDisplayXAML/App.xaml.cs new file mode 100644 index 0000000000..6e6d91df76 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/PowerDisplayXAML/App.xaml.cs @@ -0,0 +1,379 @@ +// 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.Threading; +using System.Threading.Tasks; +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.PowerToys.Telemetry; +using Microsoft.UI.Dispatching; +using Microsoft.UI.Xaml; +using Microsoft.Windows.AppLifecycle; +using PowerDisplay.Common; +using PowerDisplay.Helpers; +using PowerDisplay.Serialization; +using PowerToys.Interop; + +namespace PowerDisplay +{ + /// + /// PowerDisplay application main class + /// + public partial class App : Application + { + private readonly SettingsUtils _settingsUtils = SettingsUtils.Default; + private Window? _mainWindow; + private int _powerToysRunnerPid; + private string? _pipeName; + private TrayIconService? _trayIconService; + + public App(int runnerPid, string? pipeName) + { + Logger.LogInfo($"App constructor: Starting with runnerPid={runnerPid}, pipeName={pipeName ?? "null"}"); + _powerToysRunnerPid = runnerPid; + _pipeName = pipeName; + + Logger.LogTrace("App constructor: Calling InitializeComponent"); + this.InitializeComponent(); + + // Ensure types used in XAML are preserved for AOT compilation + TypePreservation.PreserveTypes(); + + // Note: Logger is already initialized in Program.cs before App constructor + Logger.LogTrace("App constructor: InitializeComponent completed"); + + // Initialize PowerToys telemetry + try + { + PowerToysTelemetry.Log.WriteEvent(new Telemetry.Events.PowerDisplayStartEvent()); + Logger.LogTrace("App constructor: Telemetry event sent"); + } + catch (Exception ex) + { + Logger.LogWarning($"App constructor: Telemetry failed: {ex.Message}"); + } + + // Initialize language settings + string appLanguage = LanguageHelper.LoadLanguage(); + if (!string.IsNullOrEmpty(appLanguage)) + { + Microsoft.Windows.Globalization.ApplicationLanguages.PrimaryLanguageOverride = appLanguage; + Logger.LogTrace($"App constructor: Language set to {appLanguage}"); + } + + // Handle unhandled exceptions + this.UnhandledException += OnUnhandledException; + Logger.LogInfo("App constructor: Completed"); + } + + /// + /// Handle unhandled exceptions + /// + private void OnUnhandledException(object sender, Microsoft.UI.Xaml.UnhandledExceptionEventArgs e) + { + Logger.LogError("Unhandled exception", e.Exception); + } + + /// + /// Called when the application is launched + /// + /// Launch arguments + protected override void OnLaunched(Microsoft.UI.Xaml.LaunchActivatedEventArgs args) + { + Logger.LogInfo("OnLaunched: Application launching"); + try + { + // Single instance is already ensured by AppInstance.FindOrRegisterForKey() in Program.cs + // PID is already parsed in Program.cs and passed to constructor + + // Set up Windows Events monitoring (Awake pattern) + // Note: PowerDisplay.exe should NOT listen to RefreshMonitorsEvent + // That event is sent BY PowerDisplay TO Settings UI for one-way notification + Logger.LogInfo("OnLaunched: Registering Windows Events for IPC..."); + RegisterWindowEvent(Constants.TogglePowerDisplayEvent(), mw => mw.ToggleWindow(), "Toggle"); + Logger.LogTrace($"OnLaunched: Registered Toggle event: {Constants.TogglePowerDisplayEvent()}"); + RegisterEvent(Constants.TerminatePowerDisplayEvent(), () => Environment.Exit(0), "Terminate"); + Logger.LogTrace($"OnLaunched: Registered Terminate event: {Constants.TerminatePowerDisplayEvent()}"); + RegisterWindowEvent( + Constants.SettingsUpdatedPowerDisplayEvent(), + mw => + { + mw.ViewModel.ApplySettingsFromUI(); + + // Refresh tray icon based on updated settings + _trayIconService?.SetupTrayIcon(); + }, + "SettingsUpdated"); + RegisterWindowEvent( + Constants.HotkeyUpdatedPowerDisplayEvent(), + mw => mw.ReloadHotkeySettings(), + "HotkeyUpdated"); + RegisterViewModelEvent(Constants.PowerDisplaySendSettingsTelemetryEvent(), vm => vm.SendSettingsTelemetry(), "SendSettingsTelemetry"); + + // LightSwitch integration - apply profiles when theme changes + RegisterViewModelEvent(PathConstants.LightSwitchLightThemeEventName, vm => vm.ApplyLightSwitchProfile(isLightMode: true), "LightSwitch-Light"); + RegisterViewModelEvent(PathConstants.LightSwitchDarkThemeEventName, vm => vm.ApplyLightSwitchProfile(isLightMode: false), "LightSwitch-Dark"); + Logger.LogInfo("OnLaunched: All Windows Events registered"); + + // Connect to Named Pipe for IPC with module DLL (if pipe name provided) + if (!string.IsNullOrEmpty(_pipeName)) + { + Logger.LogInfo($"OnLaunched: Starting Named Pipe processing for pipe: {_pipeName}"); + ProcessNamedPipe(_pipeName); + } + else + { + Logger.LogInfo("OnLaunched: No pipe name provided, skipping Named Pipe setup"); + } + + // Monitor Runner process (backup exit mechanism) + if (_powerToysRunnerPid > 0) + { + Logger.LogInfo($"OnLaunched: PowerDisplay started from PowerToys Runner. Runner pid={_powerToysRunnerPid}"); + + RunnerHelper.WaitForPowerToysRunner(_powerToysRunnerPid, () => + { + Logger.LogInfo("OnLaunched: PowerToys Runner exited. Exiting PowerDisplay"); + Environment.Exit(0); + }); + } + else + { + Logger.LogInfo("OnLaunched: PowerDisplay started in standalone mode (no runner PID)"); + } + + // Create main window + Logger.LogInfo("OnLaunched: Creating MainWindow"); + _mainWindow = new MainWindow(); + Logger.LogInfo("OnLaunched: MainWindow created"); + + // Initialize tray icon service + Logger.LogTrace("OnLaunched: Initializing TrayIconService"); + _trayIconService = new TrayIconService( + _settingsUtils, + ToggleMainWindow, + () => Environment.Exit(0), + OpenSettings); + _trayIconService.SetupTrayIcon(); + Logger.LogTrace("OnLaunched: TrayIconService initialized"); + + // Window visibility depends on launch mode + bool isStandaloneMode = _powerToysRunnerPid <= 0; + Logger.LogInfo($"OnLaunched: isStandaloneMode={isStandaloneMode}"); + + if (isStandaloneMode) + { + // Standalone mode - activate and show window immediately + Logger.LogInfo("OnLaunched: Activating window (standalone mode)"); + _mainWindow.Activate(); + Logger.LogInfo("OnLaunched: Window activated (standalone mode)"); + } + else + { + // PowerToys mode - window remains hidden until show event received + // Background initialization runs automatically via MainWindow constructor + Logger.LogInfo("OnLaunched: Window created but hidden, waiting for show/toggle event (PowerToys mode)"); + } + + Logger.LogInfo("OnLaunched: Application launch completed"); + } + catch (Exception ex) + { + Logger.LogError($"OnLaunched: PowerDisplay startup failed: {ex.Message}\n{ex.StackTrace}"); + } + } + + /// + /// Register a simple event handler (no window access needed) + /// + private void RegisterEvent(string eventName, Action action, string logName) + { + Logger.LogTrace($"RegisterEvent: Setting up event listener for '{logName}' on event '{eventName}'"); + NativeEventWaiter.WaitForEventLoop( + eventName, + () => + { + Logger.LogInfo($"[EVENT] {logName} event received from event '{eventName}'"); + try + { + action(); + Logger.LogTrace($"[EVENT] {logName} action completed"); + } + catch (Exception ex) + { + Logger.LogError($"[EVENT] {logName} action failed: {ex.Message}"); + } + }, + CancellationToken.None); + } + + /// + /// Register an event handler that operates on MainWindow directly + /// NativeEventWaiter already marshals to UI thread + /// + private void RegisterWindowEvent(string eventName, Action action, string logName) + { + Logger.LogTrace($"RegisterWindowEvent: Setting up window event listener for '{logName}' on event '{eventName}'"); + NativeEventWaiter.WaitForEventLoop( + eventName, + () => + { + Logger.LogInfo($"[EVENT] {logName} window event received from event '{eventName}'"); + if (_mainWindow is MainWindow mainWindow) + { + Logger.LogTrace($"[EVENT] {logName}: MainWindow is valid, invoking action"); + try + { + action(mainWindow); + Logger.LogTrace($"[EVENT] {logName}: Window action completed"); + } + catch (Exception ex) + { + Logger.LogError($"[EVENT] {logName}: Window action failed: {ex.Message}"); + } + } + else + { + Logger.LogError($"[EVENT] {logName}: _mainWindow is null or not MainWindow type"); + } + }, + CancellationToken.None); + } + + /// + /// Register an event handler that operates on ViewModel via DispatcherQueue + /// Used for Settings UI IPC events that need ViewModel access + /// + private void RegisterViewModelEvent(string eventName, Action action, string logName) + { + NativeEventWaiter.WaitForEventLoop( + eventName, + () => + { + Logger.LogInfo($"[EVENT] {logName} event received"); + _mainWindow?.DispatcherQueue.TryEnqueue(() => + { + if (_mainWindow is MainWindow mainWindow && mainWindow.ViewModel != null) + { + action(mainWindow.ViewModel); + } + }); + }, + CancellationToken.None); + } + + /// + /// Gets the main window instance + /// + public Window? MainWindow => _mainWindow; + + /// + /// Toggle the main window visibility + /// + private void ToggleMainWindow() + { + Logger.LogInfo("ToggleMainWindow: Called"); + if (_mainWindow is MainWindow mainWindow) + { + Logger.LogTrace($"ToggleMainWindow: MainWindow is valid, current visibility={mainWindow.IsWindowVisible()}"); + mainWindow.ToggleWindow(); + } + else + { + Logger.LogError("ToggleMainWindow: _mainWindow is null or not MainWindow type"); + } + } + + /// + /// Open PowerDisplay settings in PowerToys Settings UI + /// + private void OpenSettings() + { + // mainExecutableIsOnTheParentFolder = true because PowerDisplay is a WinUI 3 app + // deployed in a subfolder (PowerDisplay\) while PowerToys.exe is in the parent folder + SettingsDeepLink.OpenSettings(true); + } + + /// + /// Refresh tray icon based on current settings + /// + public void RefreshTrayIcon() + { + _trayIconService?.SetupTrayIcon(); + } + + /// + /// Check if running standalone (not launched from PowerToys Runner) + /// + public bool IsRunningDetachedFromPowerToys() + { + return _powerToysRunnerPid == -1; + } + + /// + /// Shutdown application (Awake pattern - simple and clean) + /// + public void Shutdown() + { + Logger.LogInfo("PowerDisplay shutting down"); + _trayIconService?.Destroy(); + Environment.Exit(0); + } + + /// + /// Connect to Named Pipe and process messages from module DLL + /// + private void ProcessNamedPipe(string pipeName) + { + void OnMessage(string message) => _mainWindow?.DispatcherQueue.TryEnqueue(async () => await OnNamedPipeMessage(message)); + + Task.Run(async () => await NamedPipeProcessor.ProcessNamedPipeAsync( + pipeName, + connectTimeout: TimeSpan.FromSeconds(10), + OnMessage, + CancellationToken.None)); + } + + /// + /// Handle messages received from the module DLL via Named Pipe + /// + private async Task OnNamedPipeMessage(string message) + { + var messageParts = message.Split(' ', 2); + var messageType = messageParts[0]; + + Logger.LogInfo($"[NamedPipe] Processing message type: {messageType}"); + + if (messageType == Constants.PowerDisplayToggleMessage()) + { + // Toggle window visibility + if (_mainWindow is MainWindow mainWindow) + { + mainWindow.ToggleWindow(); + } + } + else if (messageType == Constants.PowerDisplayApplyProfileMessage()) + { + // Apply profile by name + if (messageParts.Length > 1 && _mainWindow is MainWindow mainWindow && mainWindow.ViewModel != null) + { + var profileName = messageParts[1].Trim(); + Logger.LogInfo($"[NamedPipe] Applying profile: {profileName}"); + await mainWindow.ViewModel.ApplyProfileByNameAsync(profileName); + } + } + else if (messageType == Constants.PowerDisplayTerminateAppMessage()) + { + // Terminate the application + Logger.LogInfo("[NamedPipe] Received terminate message"); + Shutdown(); + } + else + { + Logger.LogWarning($"[NamedPipe] Unknown message type: {messageType}"); + } + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay/PowerDisplayXAML/IdentifyWindow.xaml b/src/modules/powerdisplay/PowerDisplay/PowerDisplayXAML/IdentifyWindow.xaml new file mode 100644 index 0000000000..7d41fdc12d --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/PowerDisplayXAML/IdentifyWindow.xaml @@ -0,0 +1,25 @@ + + + + + diff --git a/src/modules/powerdisplay/PowerDisplay/PowerDisplayXAML/IdentifyWindow.xaml.cs b/src/modules/powerdisplay/PowerDisplay/PowerDisplayXAML/IdentifyWindow.xaml.cs new file mode 100644 index 0000000000..1668aef820 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/PowerDisplayXAML/IdentifyWindow.xaml.cs @@ -0,0 +1,72 @@ +// 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.Threading.Tasks; +using Microsoft.UI.Windowing; +using Windows.Graphics; +using WinUIEx; + +namespace PowerDisplay.PowerDisplayXAML +{ + /// + /// Interaction logic for IdentifyWindow.xaml + /// + public sealed partial class IdentifyWindow : WindowEx + { + // Window size in device-independent units (DIU) + private const int WindowWidthDiu = 300; + private const int WindowHeightDiu = 280; + + private double _dpiScale = 1.0; + + public IdentifyWindow(string displayText) + { + InitializeComponent(); + NumberText.Text = displayText; + + // Configure window style + ConfigureWindow(); + + // Auto close after 3 seconds + Task.Delay(3000).ContinueWith(_ => + { + DispatcherQueue.TryEnqueue(() => + { + Close(); + }); + }); + } + + private void ConfigureWindow() + { + _dpiScale = this.GetDpiForWindow() / 96.0; + + // Set window size scaled for DPI + // AppWindow.Resize expects physical pixels + int physicalWidth = (int)(WindowWidthDiu * _dpiScale); + int physicalHeight = (int)(WindowHeightDiu * _dpiScale); + this.AppWindow.Resize(new SizeInt32 { Width = physicalWidth, Height = physicalHeight }); + this.IsAlwaysOnTop = true; + } + + /// + /// Position the window at the center of the specified display area + /// + public void PositionOnDisplay(DisplayArea displayArea) + { + var workArea = displayArea.WorkArea; + + // Window size in physical pixels (already scaled for DPI) + int physicalWidth = (int)(WindowWidthDiu * _dpiScale); + int physicalHeight = (int)(WindowHeightDiu * _dpiScale); + + // Calculate center position (WorkArea coordinates are in physical pixels) + int x = workArea.X + ((workArea.Width - physicalWidth) / 2); + int y = workArea.Y + ((workArea.Height - physicalHeight) / 2); + + // Use WindowEx's AppWindow property + this.AppWindow.Move(new PointInt32(x, y)); + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay/PowerDisplayXAML/MainWindow.xaml b/src/modules/powerdisplay/PowerDisplay/PowerDisplayXAML/MainWindow.xaml new file mode 100644 index 0000000000..7c43bc105f --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/PowerDisplayXAML/MainWindow.xaml @@ -0,0 +1,552 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/modules/powerdisplay/PowerDisplay/PowerDisplayXAML/MainWindow.xaml.cs b/src/modules/powerdisplay/PowerDisplay/PowerDisplayXAML/MainWindow.xaml.cs new file mode 100644 index 0000000000..62fa666d6e --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/PowerDisplayXAML/MainWindow.xaml.cs @@ -0,0 +1,715 @@ +// 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.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Threading.Tasks; +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.UI.Windowing; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Controls.Primitives; +using Microsoft.UI.Xaml.Input; +using PowerDisplay.Common.Models; +using PowerDisplay.Configuration; +using PowerDisplay.Helpers; +using PowerDisplay.ViewModels; +using Windows.Graphics; +using WinUIEx; +using Monitor = PowerDisplay.Common.Models.Monitor; + +namespace PowerDisplay +{ + /// + /// PowerDisplay main window + /// + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicMethods)] + public sealed partial class MainWindow : WindowEx, IDisposable + { + private readonly SettingsUtils _settingsUtils = SettingsUtils.Default; + private MainViewModel? _viewModel; + private HotkeyService? _hotkeyService; + + // Expose ViewModel as property for x:Bind + public MainViewModel ViewModel => _viewModel ?? throw new InvalidOperationException("ViewModel not initialized"); + + public MainWindow() + { + Logger.LogInfo("MainWindow constructor: Starting"); + try + { + // 1. Create ViewModel BEFORE InitializeComponent to avoid x:Bind failures + // x:Bind evaluates during InitializeComponent, so ViewModel must exist first + Logger.LogTrace("MainWindow constructor: Creating MainViewModel"); + _viewModel = new MainViewModel(); + Logger.LogTrace("MainWindow constructor: MainViewModel created"); + + Logger.LogTrace("MainWindow constructor: Calling InitializeComponent"); + this.InitializeComponent(); + Logger.LogTrace("MainWindow constructor: InitializeComponent completed"); + + // 2. Configure window immediately (synchronous, no data dependency) + Logger.LogTrace("MainWindow constructor: Configuring window"); + ConfigureWindow(); + + // 3. Set up data context and update bindings + RootGrid.DataContext = _viewModel; + Bindings.Update(); + Logger.LogTrace("MainWindow constructor: Data context set and bindings updated"); + + // 4. Register event handlers + RegisterEventHandlers(); + Logger.LogTrace("MainWindow constructor: Event handlers registered"); + + // 5. Initialize HotkeyService for in-process hotkey handling (CmdPal pattern) + // This avoids IPC timing issues with Runner's centralized hotkey mechanism + Logger.LogTrace("MainWindow constructor: Initializing HotkeyService"); + _hotkeyService = new HotkeyService(_settingsUtils, ToggleWindow); + _hotkeyService.Initialize(this); + Logger.LogTrace("MainWindow constructor: HotkeyService initialized"); + + // Note: ViewModel handles all async initialization internally. + // We listen to InitializationCompleted event to know when data is ready. + // No duplicate initialization here - single responsibility in ViewModel. + Logger.LogInfo("MainWindow constructor: Completed"); + } + catch (Exception ex) + { + Logger.LogError($"MainWindow constructor: Initialization failed: {ex.Message}\n{ex.StackTrace}"); + ShowError($"Unable to start main window: {ex.Message}"); + } + } + + /// + /// Register all event handlers for window and ViewModel + /// + private void RegisterEventHandlers() + { + // Window events + this.Closed += OnWindowClosed; + this.Activated += OnWindowActivated; + + // ViewModel events - _viewModel is guaranteed non-null here as this is called after initialization + if (_viewModel != null) + { + _viewModel.InitializationCompleted += OnViewModelInitializationCompleted; + _viewModel.UIRefreshRequested += OnUIRefreshRequested; + _viewModel.Monitors.CollectionChanged += OnMonitorsCollectionChanged; + _viewModel.PropertyChanged += OnViewModelPropertyChanged; + } + } + + /// + /// Called when ViewModel completes initial monitor discovery. + /// This is the single source of truth for initialization state. + /// + private void OnViewModelInitializationCompleted(object? sender, EventArgs e) + { + _hasInitialized = true; + Logger.LogInfo("MainWindow: Initialization completed via ViewModel event, _hasInitialized=true"); + AdjustWindowSizeToContent(); + } + + private bool _hasInitialized; + + private void ShowError(string message) + { + Logger.LogError($"Error: {message}"); + } + + private void OnWindowActivated(object sender, WindowActivatedEventArgs args) + { + Logger.LogTrace($"OnWindowActivated: WindowActivationState={args.WindowActivationState}"); + + // Auto-hide window when it loses focus (deactivated) + if (args.WindowActivationState == WindowActivationState.Deactivated) + { + Logger.LogInfo("OnWindowActivated: Window deactivated, hiding window"); + HideWindow(); + } + } + + private void OnWindowClosed(object sender, WindowEventArgs args) + { + // If only user operation (although we hide close button), just hide window + args.Handled = true; // Prevent window closing + HideWindow(); + } + + public void ShowWindow() + { + Logger.LogInfo($"ShowWindow: Called, _hasInitialized={_hasInitialized}"); + try + { + // If not initialized, log warning but continue showing + if (!_hasInitialized) + { + Logger.LogWarning("ShowWindow: Window not fully initialized yet, showing anyway"); + } + + // Adjust size BEFORE showing to prevent flicker + // This measures content and positions window at correct size + Logger.LogTrace("ShowWindow: Adjusting window size to content"); + AdjustWindowSizeToContent(); + + // CRITICAL: WinUI3 windows must be Activated at least once to display properly. + // In PowerToys mode, window is created but never activated until first show. + // Without Activate(), Show() may not actually render the window on screen. + Logger.LogTrace("ShowWindow: Calling this.Activate()"); + this.Activate(); + + // Now show the window - it should appear at the correct size + Logger.LogTrace("ShowWindow: Calling this.Show()"); + this.Show(); + + // Ensure window stays on top of other windows + this.IsAlwaysOnTop = true; + Logger.LogTrace("ShowWindow: IsAlwaysOnTop set to true"); + + // Ensure window gets keyboard focus using WinUIEx's BringToFront + // This is necessary for Tab navigation to work without clicking first + this.BringToFront(); + Logger.LogTrace("ShowWindow: BringToFront called"); + + // Clear focus from any interactive element (e.g., Slider) to prevent + // showing the value tooltip when the window opens + RootGrid.Focus(FocusState.Programmatic); + + // Verify window is visible + bool isVisible = IsWindowVisible(); + Logger.LogInfo($"ShowWindow: Window visibility after show: {isVisible}"); + if (!isVisible) + { + Logger.LogError("ShowWindow: Window not visible after show attempt, forcing visibility"); + this.Activate(); + this.Show(); + this.BringToFront(); + Logger.LogInfo($"ShowWindow: After forced show, visibility: {IsWindowVisible()}"); + } + else + { + Logger.LogInfo("ShowWindow: Window shown successfully"); + } + } + catch (Exception ex) + { + Logger.LogError($"ShowWindow: Failed to show window: {ex.Message}\n{ex.StackTrace}"); + throw; + } + } + + public void HideWindow() + { + Logger.LogInfo("HideWindow: Hiding window"); + + // Hide window + this.Hide(); + + Logger.LogTrace($"HideWindow: Window hidden, visibility now: {IsWindowVisible()}"); + } + + /// + /// Check if window is currently visible + /// + /// True if window is visible, false otherwise + public bool IsWindowVisible() + { + bool visible = this.Visible; + Logger.LogTrace($"IsWindowVisible: Returning {visible}"); + return visible; + } + + /// + /// Toggle window visibility (show if hidden, hide if visible) + /// + public void ToggleWindow() + { + bool currentlyVisible = IsWindowVisible(); + Logger.LogInfo($"ToggleWindow: Called, current visibility={currentlyVisible}"); + try + { + if (currentlyVisible) + { + Logger.LogInfo("ToggleWindow: Window is visible, hiding"); + HideWindow(); + } + else + { + Logger.LogInfo("ToggleWindow: Window is hidden, showing"); + ShowWindow(); + } + + Logger.LogInfo($"ToggleWindow: Completed, new visibility={IsWindowVisible()}"); + } + catch (Exception ex) + { + Logger.LogError($"ToggleWindow: Failed to toggle window: {ex.Message}\n{ex.StackTrace}"); + throw; + } + } + + private void OnUIRefreshRequested(object? sender, EventArgs e) + { + // Adjust window size when UI configuration changes (feature visibility toggles) + DispatcherQueue.TryEnqueue(() => AdjustWindowSizeToContent()); + } + + private void OnMonitorsCollectionChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e) + { + // Adjust window size when monitors collection changes (event-driven!) + // The UI binding will update first, then we adjust size + DispatcherQueue.TryEnqueue(Microsoft.UI.Dispatching.DispatcherQueuePriority.Low, () => + { + AdjustWindowSizeToContent(); + }); + } + + private void OnViewModelPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) + { + // Adjust window size when relevant properties change (event-driven!) + if (e.PropertyName == nameof(_viewModel.IsScanning) || + e.PropertyName == nameof(_viewModel.HasMonitors) || + e.PropertyName == nameof(_viewModel.ShowNoMonitorsMessage)) + { + // Use Low priority to ensure UI bindings update first + DispatcherQueue.TryEnqueue(Microsoft.UI.Dispatching.DispatcherQueuePriority.Low, () => + { + AdjustWindowSizeToContent(); + }); + } + } + + private void OnRefreshClick(object sender, RoutedEventArgs e) + { + try + { + // Refresh monitor list + if (_viewModel?.RefreshCommand?.CanExecute(null) == true) + { + _viewModel.RefreshCommand.Execute(null); + + // Window size will be adjusted automatically by OnMonitorsCollectionChanged event! + // No delay needed - event-driven design + } + } + catch (Exception ex) + { + Logger.LogError($"OnRefreshClick failed: {ex}"); + } + } + + private void OnSettingsClick(object sender, RoutedEventArgs e) + { + // Open PowerDisplay settings in PowerToys Settings UI + // mainExecutableIsOnTheParentFolder = true because PowerDisplay is a WinUI 3 app + // deployed in a subfolder (PowerDisplay\) while PowerToys.exe is in the parent folder + SettingsDeepLink.OpenSettings(true); + } + + /// + /// Configure window properties (synchronous, no data dependency) + /// + private void ConfigureWindow() + { + try + { + // Window properties (IsResizable, IsMaximizable, IsMinimizable, + // IsTitleBarVisible, IsShownInSwitchers) are set in XAML + + // Set minimal initial window size - will be adjusted before showing + // Using minimal height to prevent "large window shrinking" flicker + this.AppWindow.Resize(new SizeInt32 { Width = AppConstants.UI.WindowWidth, Height = 100 }); + + // Position window at bottom right corner + PositionWindowAtBottomRight(); + + // Set window title + this.AppWindow.Title = "PowerDisplay"; + + // Custom title bar - completely remove all buttons + var titleBar = this.AppWindow.TitleBar; + if (titleBar != null) + { + // Extend content into title bar area + titleBar.ExtendsContentIntoTitleBar = true; + + // Completely remove title bar height + titleBar.PreferredHeightOption = TitleBarHeightOption.Collapsed; + + // Set all button colors to transparent + titleBar.ButtonBackgroundColor = Windows.UI.Color.FromArgb(0, 0, 0, 0); + titleBar.ButtonInactiveBackgroundColor = Windows.UI.Color.FromArgb(0, 0, 0, 0); + titleBar.ButtonForegroundColor = Windows.UI.Color.FromArgb(0, 0, 0, 0); + titleBar.ButtonHoverBackgroundColor = Windows.UI.Color.FromArgb(0, 0, 0, 0); + titleBar.ButtonHoverForegroundColor = Windows.UI.Color.FromArgb(0, 0, 0, 0); + titleBar.ButtonPressedBackgroundColor = Windows.UI.Color.FromArgb(0, 0, 0, 0); + titleBar.ButtonPressedForegroundColor = Windows.UI.Color.FromArgb(0, 0, 0, 0); + titleBar.ButtonInactiveForegroundColor = Windows.UI.Color.FromArgb(0, 0, 0, 0); + + // Disable title bar interaction area + titleBar.SetDragRectangles(Array.Empty()); + } + + // Use Win32 API to further disable window moving (removes WS_CAPTION, WS_SYSMENU, etc.) + var hWnd = this.GetWindowHandle(); + WindowHelper.DisableWindowMovingAndResizing(hWnd); + } + catch (Exception ex) + { + // Ignore window setup errors + Logger.LogWarning($"Window configuration error: {ex.Message}"); + } + } + + private void AdjustWindowSizeToContent() + { + try + { + if (RootGrid == null) + { + return; + } + + // Force layout update and measure content height + RootGrid.UpdateLayout(); + MainContainer?.Measure(new Windows.Foundation.Size(AppConstants.UI.WindowWidth, double.PositiveInfinity)); + var contentHeight = (int)Math.Ceiling(MainContainer?.DesiredSize.Height ?? 0); + + // Apply min/max height limits and reposition (WindowEx handles DPI automatically) + // Min height ensures window is visible even if content hasn't loaded yet + var finalHeight = Math.Max(AppConstants.UI.MinWindowHeight, Math.Min(contentHeight, AppConstants.UI.MaxWindowHeight)); + Logger.LogTrace($"AdjustWindowSizeToContent: contentHeight={contentHeight}, finalHeight={finalHeight}"); + WindowHelper.PositionWindowBottomRight(this, AppConstants.UI.WindowWidth, finalHeight, AppConstants.UI.WindowRightMargin); + } + catch (Exception ex) + { + Logger.LogError($"Error adjusting window size: {ex.Message}"); + } + } + + private void PositionWindowAtBottomRight() + { + try + { + var windowSize = this.AppWindow.Size; + WindowHelper.PositionWindowBottomRight( + this, // MainWindow inherits from WindowEx + AppConstants.UI.WindowWidth, + windowSize.Height, + AppConstants.UI.WindowRightMargin); + } + catch (Exception) + { + // Window positioning failures are non-critical, silently ignore + } + } + + /// + /// Slider PointerCaptureLost event handler - updates ViewModel when drag completes + /// This is the WinUI3 recommended way to detect drag completion + /// + private void Slider_PointerCaptureLost(object sender, Microsoft.UI.Xaml.Input.PointerRoutedEventArgs e) + { + var slider = sender as Slider; + if (slider == null) + { + return; + } + + var propertyName = slider.Tag as string; + var monitorVm = slider.DataContext as MonitorViewModel; + + if (monitorVm == null || propertyName == null) + { + return; + } + + // Get final value after drag completes + int finalValue = (int)slider.Value; + + // Now update the ViewModel, which will trigger hardware operation + switch (propertyName) + { + case "Brightness": + monitorVm.Brightness = finalValue; + break; + case "Contrast": + monitorVm.ContrastPercent = finalValue; + break; + case "Volume": + monitorVm.Volume = finalValue; + break; + } + } + + /// + /// Slider KeyUp event handler - updates ViewModel when arrow keys are released + /// This handles keyboard navigation for accessibility + /// + private void Slider_KeyUp(object sender, Microsoft.UI.Xaml.Input.KeyRoutedEventArgs e) + { + // Only handle arrow keys (Left, Right, Up, Down) + if (e.Key != Windows.System.VirtualKey.Left && + e.Key != Windows.System.VirtualKey.Right && + e.Key != Windows.System.VirtualKey.Up && + e.Key != Windows.System.VirtualKey.Down) + { + return; + } + + var slider = sender as Slider; + if (slider == null) + { + return; + } + + var propertyName = slider.Tag as string; + var monitorVm = slider.DataContext as MonitorViewModel; + + if (monitorVm == null || propertyName == null) + { + return; + } + + // Get the current value after key press + int finalValue = (int)slider.Value; + + // Update the ViewModel, which will trigger hardware operation + switch (propertyName) + { + case "Brightness": + monitorVm.Brightness = finalValue; + break; + case "Contrast": + monitorVm.ContrastPercent = finalValue; + break; + case "Volume": + monitorVm.Volume = finalValue; + break; + } + } + + /// + /// Input source ListView selection changed handler - switches the monitor input source + /// + private async void InputSourceListView_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (sender is not ListView listView) + { + return; + } + + // Get the selected input source item + var selectedItem = listView.SelectedItem as InputSourceItem; + if (selectedItem == null) + { + return; + } + + Logger.LogInfo($"[UI] InputSourceListView_SelectionChanged: Selected {selectedItem.Name} (0x{selectedItem.Value:X2}) for monitor {selectedItem.MonitorId}"); + + // Find the monitor by ID + MonitorViewModel? monitorVm = null; + if (!string.IsNullOrEmpty(selectedItem.MonitorId) && _viewModel != null) + { + monitorVm = _viewModel.Monitors.FirstOrDefault(m => m.Id == selectedItem.MonitorId); + } + + if (monitorVm == null) + { + Logger.LogWarning("[UI] InputSourceListView_SelectionChanged: Could not find MonitorViewModel"); + return; + } + + // Set the input source + await monitorVm.SetInputSourceAsync(selectedItem.Value); + } + + /// + /// Power state ListView selection changed handler - switches the monitor power state. + /// Note: Selecting any state other than "On" will turn off the display. + /// + private async void PowerStateListView_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (sender is not ListView listView) + { + return; + } + + // Get the selected power state item + var selectedItem = listView.SelectedItem as PowerStateItem; + if (selectedItem == null) + { + return; + } + + // Skip if "On" is selected - the monitor is already on + if (selectedItem.Value == PowerStateItem.PowerStateOn) + { + return; + } + + Logger.LogInfo($"[UI] PowerStateListView_SelectionChanged: Selected {selectedItem.Name} (0x{selectedItem.Value:X2}) for monitor {selectedItem.MonitorId}"); + + // Find the monitor by ID + MonitorViewModel? monitorVm = null; + if (!string.IsNullOrEmpty(selectedItem.MonitorId) && _viewModel != null) + { + monitorVm = _viewModel.Monitors.FirstOrDefault(m => m.Id == selectedItem.MonitorId); + } + + if (monitorVm == null) + { + Logger.LogWarning("[UI] PowerStateListView_SelectionChanged: Could not find MonitorViewModel"); + return; + } + + // Set the power state - this will turn off the display + await monitorVm.SetPowerStateAsync(selectedItem.Value); + } + + /// + /// Rotation button click handler - changes monitor orientation + /// + 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); + } + + /// + /// Profile selection changed handler - applies the selected profile + /// + 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; + } + + /// + /// Color temperature selection changed handler - applies the selected color temperature preset + /// + private async void ColorTemperatureListView_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (sender is not ListView listView) + { + return; + } + + var selectedItem = listView.SelectedItem as ColorTemperatureItem; + if (selectedItem == null) + { + return; + } + + Logger.LogInfo($"[UI] ColorTemperatureListView_SelectionChanged: Selected {selectedItem.DisplayName} (0x{selectedItem.VcpValue:X2}) for monitor {selectedItem.MonitorId}"); + + // Find the monitor by ID + MonitorViewModel? monitorVm = null; + if (!string.IsNullOrEmpty(selectedItem.MonitorId) && _viewModel != null) + { + monitorVm = _viewModel.Monitors.FirstOrDefault(m => m.Id == selectedItem.MonitorId); + } + + if (monitorVm == null) + { + Logger.LogWarning("[UI] ColorTemperatureListView_SelectionChanged: Could not find MonitorViewModel"); + return; + } + + // Apply the color temperature + await monitorVm.SetColorTemperatureAsync(selectedItem.VcpValue); + + // Clear selection to allow reselecting the same preset + listView.SelectedItem = null; + } + + /// + /// Flyout opened event handler - sets focus to the first focusable element inside the flyout. + /// This enables keyboard navigation when the flyout opens. + /// + private void Flyout_Opened(object sender, object e) + { + if (sender is Flyout flyout && flyout.Content is FrameworkElement content) + { + // Use DispatcherQueue to ensure the flyout content is fully rendered before setting focus + DispatcherQueue.TryEnqueue(() => + { + var firstFocusable = FocusManager.FindFirstFocusableElement(content); + if (firstFocusable is Control control) + { + control.Focus(FocusState.Programmatic); + } + }); + } + } + + public void Dispose() + { + _hotkeyService?.Dispose(); + _viewModel?.Dispose(); + GC.SuppressFinalize(this); + } + + /// + /// Reload hotkey settings. Call this when settings change. + /// + public void ReloadHotkeySettings() + { + _hotkeyService?.ReloadSettings(); + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay/PowerDisplayXAML/MonitorIcon.xaml b/src/modules/powerdisplay/PowerDisplay/PowerDisplayXAML/MonitorIcon.xaml new file mode 100644 index 0000000000..10244cee6c --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/PowerDisplayXAML/MonitorIcon.xaml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/modules/powerdisplay/PowerDisplay/PowerDisplayXAML/MonitorIcon.xaml.cs b/src/modules/powerdisplay/PowerDisplay/PowerDisplayXAML/MonitorIcon.xaml.cs new file mode 100644 index 0000000000..3948332d0b --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/PowerDisplayXAML/MonitorIcon.xaml.cs @@ -0,0 +1,45 @@ +// 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 Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +namespace PowerDisplay; + +public sealed partial class MonitorIcon : UserControl +{ + public MonitorIcon() + { + InitializeComponent(); + } + + public bool IsBuiltIn + { + get => (bool)GetValue(IsBuiltInProperty); + set => SetValue(IsBuiltInProperty, value); + } + + public static readonly DependencyProperty IsBuiltInProperty = DependencyProperty.Register(nameof(IsBuiltIn), typeof(bool), typeof(MonitorIcon), new PropertyMetadata(false, OnPropertyChanged)); + + public int MonitorNumber + { + get => (int)GetValue(MonitorNumberProperty); + set => SetValue(MonitorNumberProperty, value); + } + + public static readonly DependencyProperty MonitorNumberProperty = DependencyProperty.Register(nameof(MonitorNumber), typeof(int), typeof(MonitorIcon), new PropertyMetadata(0, OnPropertyChanged)); + + private static void OnPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var monIcon = (MonitorIcon)d; + if (monIcon.IsBuiltIn) + { + VisualStateManager.GoToState(monIcon, "BuiltIn", true); + } + else + { + VisualStateManager.GoToState(monIcon, "Monitor", true); + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay/Program.cs b/src/modules/powerdisplay/PowerDisplay/Program.cs new file mode 100644 index 0000000000..f893741e77 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/Program.cs @@ -0,0 +1,138 @@ +// 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.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using ManagedCommon; +using Microsoft.UI.Dispatching; +using Microsoft.Windows.AppLifecycle; + +namespace PowerDisplay +{ + public static partial class Program + { + private static App? _app; + + // LibraryImport for AOT compatibility - COM wait constants + private const uint CowaitDefault = 0; + private const uint InfiniteTimeout = 0xFFFFFFFF; + + [LibraryImport("ole32.dll")] + private static partial int CoWaitForMultipleObjects( + uint dwFlags, + uint dwTimeout, + int cHandles, + nint[] pHandles, + out uint lpdwIndex); + + [STAThread] + public static int Main(string[] args) + { + // Initialize COM wrappers first (needed for AppInstance) + WinRT.ComWrappersSupport.InitializeComWrappers(); + + // Single instance check BEFORE logger initialization to avoid creating extra log files + // Command Palette pattern: check for existing instance first + var activationArgs = AppInstance.GetCurrent().GetActivatedEventArgs(); + var keyInstance = AppInstance.FindOrRegisterForKey("PowerToys_PowerDisplay_Instance"); + + if (!keyInstance.IsCurrent) + { + // Another instance exists - redirect and exit WITHOUT initializing logger + // This prevents creation of extra log files for short-lived redirect processes + RedirectActivationTo(activationArgs, keyInstance); + return 0; + } + + // This is the primary instance - now initialize logger + Logger.InitializeLogger("\\PowerDisplay\\Logs"); + Logger.LogInfo("PowerDisplay starting"); + + // Register activation handler for future redirects + keyInstance.Activated += OnActivated; + + // Parse command line arguments: + // args[0] = runner_pid (Awake pattern) + // args[1] = pipe_name (Named Pipe for IPC with module DLL) + int runnerPid = -1; + string? pipeName = null; + + if (args.Length >= 1) + { + if (int.TryParse(args[0], out int parsedPid)) + { + runnerPid = parsedPid; + } + } + + if (args.Length >= 2) + { + pipeName = args[1]; + } + + Microsoft.UI.Xaml.Application.Start((p) => + { + var context = new DispatcherQueueSynchronizationContext(DispatcherQueue.GetForCurrentThread()); + SynchronizationContext.SetSynchronizationContext(context); + _app = new App(runnerPid, pipeName); + }); + return 0; + } + + /// + /// Redirect activation to existing instance (Command Palette pattern) + /// Called BEFORE logger is initialized, so no logging here + /// + private static void RedirectActivationTo(AppActivationArguments args, AppInstance keyInstance) + { + // Do the redirection on another thread, and use a non-blocking + // wait method to wait for the redirection to complete. + using var redirectSemaphore = new Semaphore(0, 1); + var redirectTimeout = TimeSpan.FromSeconds(10); + + _ = Task.Run(() => + { + using var cts = new CancellationTokenSource(redirectTimeout); + try + { + keyInstance.RedirectActivationToAsync(args) + .AsTask(cts.Token) + .GetAwaiter() + .GetResult(); + } + catch + { + // Silently ignore errors - logger not initialized yet + } + finally + { + redirectSemaphore.Release(); + } + }); + + // Use CoWaitForMultipleObjects to pump COM messages while waiting + nint[] handles = [redirectSemaphore.SafeWaitHandle.DangerousGetHandle()]; + _ = CoWaitForMultipleObjects( + CowaitDefault, + InfiniteTimeout, + 1, + handles, + out _); + } + + /// + /// Called when an existing instance is activated by another process. + /// This happens when EnsureProcessRunning() launches a new process while one is already running. + /// We intentionally don't show the window here - window visibility should only be controlled via: + /// - Toggle event (hotkey, tray icon click, Settings UI Launch button) + /// - Standalone mode startup (handled in OnLaunched) + /// + private static void OnActivated(object? sender, AppActivationArguments args) + { + Logger.LogInfo("OnActivated: Redirect activation received - window visibility unchanged"); + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay/Serialization/IPCMessageAction.cs b/src/modules/powerdisplay/PowerDisplay/Serialization/IPCMessageAction.cs new file mode 100644 index 0000000000..b929db1b52 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/Serialization/IPCMessageAction.cs @@ -0,0 +1,18 @@ +// 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.Text.Json.Serialization; + +namespace PowerDisplay.Serialization +{ + /// + /// IPC message wrapper for parsing action-based messages. + /// Used in App.xaml.cs for dynamic IPC command handling. + /// + internal sealed class IpcMessageAction + { + [JsonPropertyName("action")] + public string? Action { get; set; } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay/Serialization/JsonSourceGenerationContext.cs b/src/modules/powerdisplay/PowerDisplay/Serialization/JsonSourceGenerationContext.cs new file mode 100644 index 0000000000..239d777693 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/Serialization/JsonSourceGenerationContext.cs @@ -0,0 +1,45 @@ +// 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.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.PowerToys.Settings.UI.Library; +using PowerDisplay.Common.Models; + +namespace PowerDisplay.Serialization +{ + /// + /// JSON source generation context for AOT compatibility. + /// Eliminates reflection-based JSON serialization. + /// Note: MonitorStateFile and MonitorStateEntry are now in PowerDisplay.Lib + /// and should be serialized using ProfileSerializationContext from the Lib. + /// + [JsonSerializable(typeof(IpcMessageAction))] + [JsonSerializable(typeof(PowerDisplaySettings))] + [JsonSerializable(typeof(PowerDisplayProfiles))] + [JsonSerializable(typeof(PowerDisplayProfile))] + [JsonSerializable(typeof(ProfileMonitorSetting))] + + // MonitorInfo and related types (Settings.UI.Library) + [JsonSerializable(typeof(MonitorInfo))] + [JsonSerializable(typeof(VcpCodeDisplayInfo))] + [JsonSerializable(typeof(VcpValueInfo))] + + // Generic collection types + [JsonSerializable(typeof(List))] + [JsonSerializable(typeof(List))] + [JsonSerializable(typeof(List))] + [JsonSerializable(typeof(List))] + [JsonSerializable(typeof(List))] + [JsonSerializable(typeof(List))] + + [JsonSourceGenerationOptions( + WriteIndented = true, + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.Never)] + internal sealed partial class AppJsonContext : JsonSerializerContext + { + } +} diff --git a/src/modules/powerdisplay/PowerDisplay/Services/LightSwitchService.cs b/src/modules/powerdisplay/PowerDisplay/Services/LightSwitchService.cs new file mode 100644 index 0000000000..7182fd32ed --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/Services/LightSwitchService.cs @@ -0,0 +1,77 @@ +// 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 Microsoft.PowerToys.Settings.UI.Library; +using Settings.UI.Library; + +namespace PowerDisplay.Services +{ + /// + /// Service for handling LightSwitch theme change events. + /// Reads LightSwitch settings using the standard PowerToys settings pattern. + /// + public static class LightSwitchService + { + private const string LogPrefix = "[LightSwitch]"; + + /// + /// Get the profile name to apply for the given theme. + /// + /// Whether the theme changed to light mode. + /// The profile name to apply, or null if no profile is configured. + public static string? GetProfileForTheme(bool isLightMode) + { + try + { + Logger.LogInfo($"{LogPrefix} Processing theme change to {(isLightMode ? "light" : "dark")} mode"); + + var settings = SettingsUtils.Default.GetSettingsOrDefault(LightSwitchSettings.ModuleName); + + if (settings?.Properties == null) + { + Logger.LogWarning($"{LogPrefix} LightSwitch settings not found"); + return null; + } + + string? profileName; + if (isLightMode) + { + if (!settings.Properties.EnableLightModeProfile.Value) + { + Logger.LogInfo($"{LogPrefix} Light mode profile is disabled"); + return null; + } + + profileName = settings.Properties.LightModeProfile.Value; + } + else + { + if (!settings.Properties.EnableDarkModeProfile.Value) + { + Logger.LogInfo($"{LogPrefix} Dark mode profile is disabled"); + return null; + } + + profileName = settings.Properties.DarkModeProfile.Value; + } + + if (string.IsNullOrEmpty(profileName) || profileName == "(None)") + { + Logger.LogInfo($"{LogPrefix} No profile configured for {(isLightMode ? "light" : "dark")} mode"); + return null; + } + + Logger.LogInfo($"{LogPrefix} Profile to apply: {profileName}"); + return profileName; + } + catch (Exception ex) + { + Logger.LogError($"{LogPrefix} Failed to get profile for theme: {ex.Message}"); + return null; + } + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay/Strings/en-us/Resources.resw b/src/modules/powerdisplay/PowerDisplay/Strings/en-us/Resources.resw new file mode 100644 index 0000000000..166140cb1f --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/Strings/en-us/Resources.resw @@ -0,0 +1,96 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Scanning monitors.. + + + No monitors detected + + + Rescan connected monitors + + + Settings + + + Monitor + + + Brightness + + + Contrast + + + Volume + + + Rotation + + + Normal (0°) + + + Rotate left (270°) + + + Rotate right (90°) + + + Inverted (180°) + + + Volume + + + Contrast + + + Brightness + + + PowerDisplay + + + Settings + + + Exit + + + Quick apply profiles + + + Identify monitors + + + Input source + + + Power state + + + More options + + + Profiles + + + Color temperature + + + Color temperature + + diff --git a/src/modules/powerdisplay/PowerDisplay/Telemetry/Events/PowerDisplaySettingsTelemetryEvent.cs b/src/modules/powerdisplay/PowerDisplay/Telemetry/Events/PowerDisplaySettingsTelemetryEvent.cs new file mode 100644 index 0000000000..d29976742f --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/Telemetry/Events/PowerDisplaySettingsTelemetryEvent.cs @@ -0,0 +1,42 @@ +// 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.Diagnostics.Tracing; +using Microsoft.PowerToys.Telemetry; +using Microsoft.PowerToys.Telemetry.Events; + +namespace PowerDisplay.Telemetry.Events +{ + /// + /// Telemetry event for PowerDisplay settings + /// Sent when Runner requests settings telemetry via send_settings_telemetry() + /// + [EventData] + public class PowerDisplaySettingsTelemetryEvent : EventBase, IEvent + { + public new string EventName => "PowerDisplay_Settings"; + + public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage; + + /// + /// Whether the hotkey is enabled + /// + public bool HotkeyEnabled { get; set; } + + /// + /// Whether the tray icon is enabled + /// + public bool TrayIconEnabled { get; set; } + + /// + /// Number of monitors currently detected + /// + public int MonitorCount { get; set; } + + /// + /// Number of profiles saved + /// + public int ProfileCount { get; set; } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay/Telemetry/Events/PowerDisplayStartEvent.cs b/src/modules/powerdisplay/PowerDisplay/Telemetry/Events/PowerDisplayStartEvent.cs new file mode 100644 index 0000000000..397fc722e2 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/Telemetry/Events/PowerDisplayStartEvent.cs @@ -0,0 +1,18 @@ +// 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.Diagnostics.Tracing; +using Microsoft.PowerToys.Telemetry; +using Microsoft.PowerToys.Telemetry.Events; + +namespace PowerDisplay.Telemetry.Events +{ + [EventData] + public class PowerDisplayStartEvent : EventBase, IEvent + { + public new string EventName => "PowerDisplay_Start"; + + public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage; + } +} diff --git a/src/modules/powerdisplay/PowerDisplay/ViewModels/ColorTemperatureItem.cs b/src/modules/powerdisplay/PowerDisplay/ViewModels/ColorTemperatureItem.cs new file mode 100644 index 0000000000..281dd2d3a4 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/ViewModels/ColorTemperatureItem.cs @@ -0,0 +1,31 @@ +// 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. + +namespace PowerDisplay.ViewModels; + +/// +/// Represents a color temperature preset option for display in UI +/// +public class ColorTemperatureItem +{ + /// + /// VCP value for this color temperature preset (e.g., 0x05 for 6500K) + /// + public int VcpValue { get; set; } + + /// + /// Human-readable name (e.g., "6500K", "sRGB", "User 1") + /// + public string DisplayName { get; set; } = string.Empty; + + /// + /// Whether this preset is currently selected + /// + public bool IsSelected { get; set; } + + /// + /// Monitor ID for direct lookup (Flyout popup is not in visual tree) + /// + public string MonitorId { get; set; } = string.Empty; +} diff --git a/src/modules/powerdisplay/PowerDisplay/ViewModels/InputSourceItem.cs b/src/modules/powerdisplay/PowerDisplay/ViewModels/InputSourceItem.cs new file mode 100644 index 0000000000..25a53efbe0 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/ViewModels/InputSourceItem.cs @@ -0,0 +1,33 @@ +// 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 Microsoft.UI.Xaml; + +namespace PowerDisplay.ViewModels; + +/// +/// Represents an input source option for display in UI +/// +public class InputSourceItem +{ + /// + /// VCP value for this input source (e.g., 0x11 for HDMI-1) + /// + public int Value { get; set; } + + /// + /// Human-readable name (e.g., "HDMI-1", "DisplayPort-1") + /// + public string Name { get; set; } = string.Empty; + + /// + /// Visibility of selection indicator (Visible when selected) + /// + public Visibility SelectionVisibility { get; set; } = Visibility.Collapsed; + + /// + /// Monitor ID for direct lookup (Flyout popup is not in visual tree) + /// + public string MonitorId { get; set; } = string.Empty; +} diff --git a/src/modules/powerdisplay/PowerDisplay/ViewModels/MainViewModel.Monitors.cs b/src/modules/powerdisplay/PowerDisplay/ViewModels/MainViewModel.Monitors.cs new file mode 100644 index 0000000000..02b3a35009 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/ViewModels/MainViewModel.Monitors.cs @@ -0,0 +1,165 @@ +// 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.Linq; +using System.Threading; +using System.Threading.Tasks; +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library; +using PowerDisplay.Common.Models; +using PowerDisplay.Helpers; +using Monitor = PowerDisplay.Common.Models.Monitor; + +namespace PowerDisplay.ViewModels; + +/// +/// MainViewModel - Monitor discovery and management methods +/// +public partial class MainViewModel +{ + private async Task InitializeAsync(CancellationToken cancellationToken = default) + { + try + { + IsScanning = true; + + // Discover monitors + var monitors = await _monitorManager.DiscoverMonitorsAsync(cancellationToken); + + // Update UI on the dispatcher thread, then complete initialization asynchronously + _dispatcherQueue.TryEnqueue(() => + { + try + { + UpdateMonitorList(monitors, isInitialLoad: true); + + // Complete initialization asynchronously (restore settings if enabled) + // IsScanning remains true until restore completes + _ = CompleteInitializationAsync(); + } + catch (Exception lambdaEx) + { + Logger.LogError($"[InitializeAsync] UI update failed: {lambdaEx.Message}"); + IsScanning = false; + } + }); + } + catch (Exception ex) + { + Logger.LogError($"[InitializeAsync] Monitor discovery failed: {ex.Message}"); + _dispatcherQueue.TryEnqueue(() => + { + IsScanning = false; + }); + } + } + + /// + /// Complete initialization by restoring settings (if enabled) and firing completion event. + /// IsScanning remains true until this method completes, so user sees discovery UI during restore. + /// + private async Task CompleteInitializationAsync() + { + try + { + // Check if we should restore settings on startup + var settings = _settingsUtils.GetSettingsOrDefault(PowerDisplaySettings.ModuleName); + if (settings.Properties.RestoreSettingsOnStartup) + { + await RestoreMonitorSettingsAsync(); + } + } + catch (Exception ex) + { + Logger.LogError($"[CompleteInitializationAsync] Failed to restore settings: {ex.Message}"); + } + finally + { + // Always complete initialization, even if restore failed + IsScanning = false; + IsInitialized = true; + + // Start watching for display changes after initialization + StartDisplayWatching(); + + // Notify listeners that initialization is complete + InitializationCompleted?.Invoke(this, EventArgs.Empty); + } + } + + /// + /// Refresh monitors list asynchronously. + /// + /// If true, skip the IsScanning check (used by OnDisplayChanged which sets IsScanning before calling). + public async Task RefreshMonitorsAsync(bool skipScanningCheck = false) + { + if (!skipScanningCheck && IsScanning) + { + return; + } + + try + { + IsScanning = true; + + var monitors = await _monitorManager.DiscoverMonitorsAsync(_cancellationTokenSource.Token); + + _dispatcherQueue.TryEnqueue(() => + { + UpdateMonitorList(monitors, isInitialLoad: false); + IsScanning = false; + }); + } + catch (Exception ex) + { + Logger.LogError($"[RefreshMonitorsAsync] Refresh failed: {ex.Message}"); + _dispatcherQueue.TryEnqueue(() => + { + IsScanning = false; + }); + } + } + + private void UpdateMonitorList(IReadOnlyList monitors, bool isInitialLoad) + { + Monitors.Clear(); + + // Load settings to check for hidden monitors + var settings = _settingsUtils.GetSettingsOrDefault(PowerDisplaySettings.ModuleName); + var hiddenMonitorIds = GetHiddenMonitorIds(settings); + + foreach (var monitor in monitors) + { + // Skip monitors that are marked as hidden in settings + if (hiddenMonitorIds.Contains(monitor.Id)) + { + continue; + } + + var vm = new MonitorViewModel(monitor, _monitorManager, this); + ApplyFeatureVisibility(vm, settings); + Monitors.Add(vm); + } + + OnPropertyChanged(nameof(HasMonitors)); + OnPropertyChanged(nameof(ShowNoMonitorsMessage)); + + // Save monitor information to settings + SaveMonitorsToSettings(); + + // Note: RestoreMonitorSettingsAsync is now called from InitializeAsync/CompleteInitializationAsync + // to ensure scanning state is maintained until restore completes + } + + /// + /// Get set of hidden monitor IDs from settings + /// + private HashSet GetHiddenMonitorIds(PowerDisplaySettings settings) + => new HashSet( + settings.Properties.Monitors + .Where(m => m.IsHidden) + .Select(m => m.Id)); +} diff --git a/src/modules/powerdisplay/PowerDisplay/ViewModels/MainViewModel.Settings.cs b/src/modules/powerdisplay/PowerDisplay/ViewModels/MainViewModel.Settings.cs new file mode 100644 index 0000000000..dc9e7ccbbd --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/ViewModels/MainViewModel.Settings.cs @@ -0,0 +1,550 @@ +// 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.Linq; +using System.Threading.Tasks; +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.PowerToys.Telemetry; +using PowerDisplay.Common.Models; +using PowerDisplay.Common.Services; +using PowerDisplay.Common.Utils; +using PowerDisplay.Serialization; +using PowerDisplay.Services; +using PowerDisplay.Telemetry.Events; +using PowerToys.Interop; + +namespace PowerDisplay.ViewModels; + +/// +/// MainViewModel - Settings UI synchronization and Profile management methods +/// +public partial class MainViewModel +{ + /// + /// Check if a value is within the valid range (inclusive). + /// + private static bool IsValueInRange(int value, int min, int max) => value >= min && value <= max; + + /// + /// Apply settings changes from Settings UI (IPC event handler entry point) + /// Only applies UI configuration changes. Hardware parameter changes (e.g., color temperature) + /// should be triggered via custom actions to avoid unwanted side effects when non-hardware + /// settings (like RestoreSettingsOnStartup) are changed. + /// + public void ApplySettingsFromUI() + { + try + { + // Rebuild monitor list with updated hidden monitor settings + // UpdateMonitorList already handles filtering hidden monitors + UpdateMonitorList(_monitorManager.Monitors, isInitialLoad: false); + + // Apply UI configuration changes only (feature visibility toggles, etc.) + // Hardware parameters (brightness, color temperature) are applied via custom actions + var settings = _settingsUtils.GetSettingsOrDefault("PowerDisplay"); + ApplyUIConfiguration(settings); + + // Reload profiles in case they were added/updated/deleted in Settings UI + LoadProfiles(); + + // Reload UI display settings (profile switcher, identify button, color temp switcher) + LoadUIDisplaySettings(); + } + catch (Exception ex) + { + Logger.LogError($"[Settings] Failed to apply settings from UI: {ex.Message}"); + } + } + + /// + /// Apply UI-only configuration changes (feature visibility toggles) + /// Synchronous, lightweight operation + /// + private void ApplyUIConfiguration(PowerDisplaySettings settings) + { + try + { + foreach (var monitorVm in Monitors) + { + ApplyFeatureVisibility(monitorVm, settings); + } + + // Trigger UI refresh + UIRefreshRequested?.Invoke(this, EventArgs.Empty); + } + catch (Exception ex) + { + Logger.LogError($"[Settings] Failed to apply UI configuration: {ex.Message}"); + } + } + + /// + /// Apply profile by name (called via Named Pipe from Settings UI) + /// This is the new direct method that receives the profile name via IPC. + /// + /// The name of the profile to apply. + public async Task ApplyProfileByNameAsync(string profileName) + { + try + { + Logger.LogInfo($"[Profile] Applying profile by name: {profileName}"); + + // Load profiles and find the requested one + var profilesData = ProfileService.LoadProfiles(); + var profile = profilesData.GetProfile(profileName); + + if (profile == null || !profile.IsValid()) + { + Logger.LogWarning($"[Profile] Profile '{profileName}' not found or invalid"); + return; + } + + // Apply the profile settings to monitors + await ApplyProfileAsync(profile.MonitorSettings); + Logger.LogInfo($"[Profile] Successfully applied profile: {profileName}"); + } + catch (Exception ex) + { + Logger.LogError($"[Profile] Failed to apply profile '{profileName}': {ex.Message}"); + } + } + + /// + /// Handle theme change from LightSwitch by applying the appropriate profile. + /// Called from App.xaml.cs when LightSwitch theme events are received. + /// + /// Whether the theme changed to light mode. + public void ApplyLightSwitchProfile(bool isLightMode) + { + var profileName = LightSwitchService.GetProfileForTheme(isLightMode); + + if (string.IsNullOrEmpty(profileName)) + { + return; + } + + _ = Task.Run(async () => + { + try + { + Logger.LogInfo($"[LightSwitch Integration] Applying profile: {profileName}"); + + // Load and apply the profile + var profilesData = ProfileService.LoadProfiles(); + var profile = profilesData.GetProfile(profileName); + + if (profile == null || !profile.IsValid()) + { + Logger.LogWarning($"[LightSwitch Integration] Profile '{profileName}' not found or invalid"); + return; + } + + // Apply the profile - need to dispatch to UI thread since MonitorViewModels are UI-bound + var tcs = new TaskCompletionSource(); + var enqueued = _dispatcherQueue.TryEnqueue(() => + { + // Start the async operation and handle completion + _ = ApplyProfileAndCompleteAsync(profile.MonitorSettings, tcs); + }); + + if (!enqueued) + { + Logger.LogError($"[LightSwitch Integration] Failed to enqueue profile application to UI thread"); + return; + } + + await tcs.Task; + } + catch (Exception ex) + { + Logger.LogError($"[LightSwitch Integration] Failed to apply profile: {ex.GetType().Name}: {ex.Message}"); + if (ex.InnerException != null) + { + Logger.LogError($"[LightSwitch Integration] Inner exception: {ex.InnerException.GetType().Name}: {ex.InnerException.Message}"); + } + } + }); + } + + /// + /// Helper method to apply profile and signal completion. + /// + private async Task ApplyProfileAndCompleteAsync(List monitorSettings, TaskCompletionSource tcs) + { + try + { + await ApplyProfileAsync(monitorSettings); + tcs.SetResult(true); + } + catch (Exception ex) + { + tcs.SetException(ex); + } + } + + /// + /// Apply profile settings to monitors + /// + private async Task ApplyProfileAsync(List monitorSettings) + { + var updateTasks = new List(); + + foreach (var setting in monitorSettings) + { + // Find monitor by Id (unique identifier) + var monitorVm = Monitors.FirstOrDefault(m => m.Id == setting.MonitorId); + + if (monitorVm == null) + { + continue; + } + + // Apply brightness if included in profile + if (setting.Brightness.HasValue && + IsValueInRange(setting.Brightness.Value, monitorVm.MinBrightness, monitorVm.MaxBrightness)) + { + updateTasks.Add(monitorVm.SetBrightnessAsync(setting.Brightness.Value)); + } + + // Apply contrast if supported and value provided + if (setting.Contrast.HasValue && monitorVm.ShowContrast && + IsValueInRange(setting.Contrast.Value, monitorVm.MinContrast, monitorVm.MaxContrast)) + { + updateTasks.Add(monitorVm.SetContrastAsync(setting.Contrast.Value)); + } + + // Apply volume if supported and value provided + if (setting.Volume.HasValue && monitorVm.ShowVolume && + IsValueInRange(setting.Volume.Value, monitorVm.MinVolume, monitorVm.MaxVolume)) + { + updateTasks.Add(monitorVm.SetVolumeAsync(setting.Volume.Value)); + } + + // Apply color temperature if included in profile + if (setting.ColorTemperatureVcp.HasValue && setting.ColorTemperatureVcp.Value > 0) + { + updateTasks.Add(monitorVm.SetColorTemperatureAsync(setting.ColorTemperatureVcp.Value)); + } + } + + // Wait for all updates to complete + if (updateTasks.Count > 0) + { + await Task.WhenAll(updateTasks); + } + } + + /// + /// Restore monitor settings from state file - ONLY called at startup when RestoreSettingsOnStartup is enabled. + /// Compares saved values with current hardware values and only writes when different. + /// + public async Task RestoreMonitorSettingsAsync() + { + try + { + IsLoading = true; + var updateTasks = new List(); + + foreach (var monitorVm in Monitors) + { + var savedState = _stateManager.GetMonitorParameters(monitorVm.Id); + if (!savedState.HasValue) + { + continue; + } + + // Restore brightness if different from current + if (IsValueInRange(savedState.Value.Brightness, monitorVm.MinBrightness, monitorVm.MaxBrightness) && + savedState.Value.Brightness != monitorVm.Brightness) + { + updateTasks.Add(monitorVm.SetBrightnessAsync(savedState.Value.Brightness)); + } + + // Restore color temperature if different from current + if (savedState.Value.ColorTemperatureVcp > 0 && + savedState.Value.ColorTemperatureVcp != monitorVm.ColorTemperature) + { + updateTasks.Add(monitorVm.SetColorTemperatureAsync(savedState.Value.ColorTemperatureVcp)); + } + + // Restore contrast if different from current + if (monitorVm.ShowContrast && + IsValueInRange(savedState.Value.Contrast, monitorVm.MinContrast, monitorVm.MaxContrast) && + savedState.Value.Contrast != monitorVm.Contrast) + { + updateTasks.Add(monitorVm.SetContrastAsync(savedState.Value.Contrast)); + } + + // Restore volume if different from current + if (monitorVm.ShowVolume && + IsValueInRange(savedState.Value.Volume, monitorVm.MinVolume, monitorVm.MaxVolume) && + savedState.Value.Volume != monitorVm.Volume) + { + updateTasks.Add(monitorVm.SetVolumeAsync(savedState.Value.Volume)); + } + } + + if (updateTasks.Count > 0) + { + await Task.WhenAll(updateTasks); + } + } + catch (Exception ex) + { + Logger.LogError($"[RestoreMonitorSettings] Failed: {ex.Message}"); + } + finally + { + IsLoading = false; + } + } + + /// + /// Apply feature visibility settings to a monitor ViewModel + /// + private void ApplyFeatureVisibility(MonitorViewModel monitorVm, PowerDisplaySettings settings) + { + var monitorSettings = settings.Properties.Monitors.FirstOrDefault(m => + m.Id == monitorVm.Id); + + if (monitorSettings != null) + { + monitorVm.ShowContrast = monitorSettings.EnableContrast; + monitorVm.ShowVolume = monitorSettings.EnableVolume; + monitorVm.ShowInputSource = monitorSettings.EnableInputSource; + monitorVm.ShowRotation = monitorSettings.EnableRotation; + monitorVm.ShowColorTemperature = monitorSettings.EnableColorTemperature; + monitorVm.ShowPowerState = monitorSettings.EnablePowerState; + } + } + + /// + /// Thread-safe save method that can be called from background threads. + /// Does not access UI collections or update UI properties. + /// + public void SaveMonitorSettingDirect(string monitorId, string property, int value) + { + try + { + // This is thread-safe - _stateManager has internal locking + // No UI thread operations, no ObservableCollection access + _stateManager.UpdateMonitorParameter(monitorId, property, value); + } + catch (Exception ex) + { + // Only log, don't update UI from background thread + Logger.LogError($"Failed to queue setting save for monitorId '{monitorId}': {ex.Message}"); + } + } + + /// + /// Save monitor information to settings.json for Settings UI to read + /// + private void SaveMonitorsToSettings() + { + try + { + // Load current settings to preserve user preferences (including IsHidden) + var settings = _settingsUtils.GetSettingsOrDefault(PowerDisplaySettings.ModuleName); + + // Create lookup of existing monitors by Id to preserve settings + // Filter out monitors with empty IDs to avoid dictionary key collision errors + var existingMonitorSettings = settings.Properties.Monitors + .Where(m => !string.IsNullOrEmpty(m.Id)) + .GroupBy(m => m.Id) + .ToDictionary(g => g.Key, g => g.First()); + + // Build monitor list using Settings UI's MonitorInfo model + // Only include monitors with valid (non-empty) IDs to auto-fix corrupted settings + var monitors = new List(); + + foreach (var vm in Monitors) + { + // Skip monitors with empty IDs - they are invalid and would cause issues + if (string.IsNullOrEmpty(vm.Id)) + { + Logger.LogWarning($"[SaveMonitors] Skipping monitor '{vm.Name}' with empty Id"); + continue; + } + + var monitorInfo = CreateMonitorInfo(vm); + ApplyPreservedUserSettings(monitorInfo, existingMonitorSettings); + monitors.Add(monitorInfo); + } + + // Also add hidden monitors from existing settings (monitors that are hidden but still connected) + // Only include those with valid IDs + foreach (var existingMonitor in settings.Properties.Monitors.Where(m => m.IsHidden && !string.IsNullOrEmpty(m.Id))) + { + // Only add if not already in the list (to avoid duplicates) + if (!monitors.Any(m => m.Id == existingMonitor.Id)) + { + monitors.Add(existingMonitor); + } + } + + // Update monitors list + settings.Properties.Monitors = monitors; + + // Save back to settings.json using source-generated context for AOT + _settingsUtils.SaveSettings( + System.Text.Json.JsonSerializer.Serialize(settings, AppJsonContext.Default.PowerDisplaySettings), + PowerDisplaySettings.ModuleName); + + // Signal Settings UI that monitor list has been updated + SignalMonitorsRefreshEvent(); + } + catch (Exception ex) + { + Logger.LogError($"Failed to save monitors to settings.json: {ex.Message}"); + } + } + + /// + /// Create MonitorInfo object from MonitorViewModel + /// + private Microsoft.PowerToys.Settings.UI.Library.MonitorInfo CreateMonitorInfo(MonitorViewModel vm) + { + // Validate monitor Id - this should never be empty for properly discovered monitors + if (string.IsNullOrEmpty(vm.Id)) + { + Logger.LogWarning($"[CreateMonitorInfo] Monitor '{vm.Name}' has empty Id - this may cause issues with Settings UI"); + } + + var monitorInfo = new Microsoft.PowerToys.Settings.UI.Library.MonitorInfo + { + Name = vm.Name, + Id = vm.Id, + CommunicationMethod = vm.CommunicationMethod, + CurrentBrightness = vm.Brightness, + ColorTemperatureVcp = vm.ColorTemperature, + CapabilitiesRaw = vm.CapabilitiesRaw, + VcpCodesFormatted = vm.VcpCapabilitiesInfo?.GetSortedVcpCodes() + .Select(info => FormatVcpCodeForDisplay(info.Code, info)) + .ToList() ?? new List(), + + // Infer support flags from VCP capabilities + // VCP 0x12 (18) = Contrast, 0x14 (20) = Color Temperature, 0x60 (96) = Input Source, 0x62 (98) = Volume, 0xD6 (214) = Power Mode + SupportsContrast = vm.VcpCapabilitiesInfo?.SupportedVcpCodes.ContainsKey(0x12) ?? false, + SupportsColorTemperature = vm.VcpCapabilitiesInfo?.SupportedVcpCodes.ContainsKey(0x14) ?? false, + SupportsInputSource = vm.VcpCapabilitiesInfo?.SupportedVcpCodes.ContainsKey(0x60) ?? false, + SupportsVolume = vm.VcpCapabilitiesInfo?.SupportedVcpCodes.ContainsKey(0x62) ?? false, + SupportsPowerState = vm.VcpCapabilitiesInfo?.SupportedVcpCodes.ContainsKey(0xD6) ?? false, + + // Default Enable* to match Supports* for new monitors (first-time setup) + // ApplyPreservedUserSettings will override these with saved user preferences if they exist + EnableContrast = vm.VcpCapabilitiesInfo?.SupportedVcpCodes.ContainsKey(0x12) ?? false, + EnableVolume = vm.VcpCapabilitiesInfo?.SupportedVcpCodes.ContainsKey(0x62) ?? false, + EnableInputSource = vm.VcpCapabilitiesInfo?.SupportedVcpCodes.ContainsKey(0x60) ?? false, + EnableColorTemperature = vm.VcpCapabilitiesInfo?.SupportedVcpCodes.ContainsKey(0x14) ?? false, + EnablePowerState = vm.VcpCapabilitiesInfo?.SupportedVcpCodes.ContainsKey(0xD6) ?? false, + + // Monitor number for display name formatting + MonitorNumber = vm.MonitorNumber, + }; + + return monitorInfo; + } + + /// + /// Apply preserved user settings from existing monitor settings + /// + private void ApplyPreservedUserSettings( + Microsoft.PowerToys.Settings.UI.Library.MonitorInfo monitorInfo, + Dictionary existingSettings) + { + if (existingSettings.TryGetValue(monitorInfo.Id, out var existingMonitor)) + { + monitorInfo.IsHidden = existingMonitor.IsHidden; + monitorInfo.EnableContrast = existingMonitor.EnableContrast; + monitorInfo.EnableVolume = existingMonitor.EnableVolume; + monitorInfo.EnableInputSource = existingMonitor.EnableInputSource; + monitorInfo.EnableRotation = existingMonitor.EnableRotation; + monitorInfo.EnableColorTemperature = existingMonitor.EnableColorTemperature; + monitorInfo.EnablePowerState = existingMonitor.EnablePowerState; + } + } + + /// + /// Signal Settings UI that the monitor list has been refreshed + /// + private void SignalMonitorsRefreshEvent() + { + EventHelper.SignalEvent(Constants.RefreshPowerDisplayMonitorsEvent()); + } + + /// + /// Format VCP code information for display in Settings UI + /// + private Microsoft.PowerToys.Settings.UI.Library.VcpCodeDisplayInfo FormatVcpCodeForDisplay(byte code, VcpCodeInfo info) + { + var result = new Microsoft.PowerToys.Settings.UI.Library.VcpCodeDisplayInfo + { + Code = info.FormattedCode, + Title = info.FormattedTitle, + }; + + if (info.IsContinuous) + { + result.Values = "Continuous range"; + result.HasValues = true; + } + else if (info.HasDiscreteValues) + { + var formattedValues = info.SupportedValues + .Select(v => Common.Utils.VcpNames.GetFormattedValueName(code, v)) + .ToList(); + result.Values = $"Values: {string.Join(", ", formattedValues)}"; + result.HasValues = true; + + // Populate value list for Settings UI ComboBox + // Store raw name (without formatting) so Settings UI can format it consistently + result.ValueList = info.SupportedValues + .Select(v => new Microsoft.PowerToys.Settings.UI.Library.VcpValueInfo + { + Value = $"0x{v:X2}", + Name = Common.Utils.VcpNames.GetValueName(code, v), + }) + .ToList(); + } + else + { + result.HasValues = false; + } + + return result; + } + + /// + /// Send settings telemetry event (triggered by Runner via send_settings_telemetry()) + /// + public void SendSettingsTelemetry() + { + try + { + // Load current settings to get hotkey and tray icon status + var settings = _settingsUtils.GetSettingsOrDefault(PowerDisplaySettings.ModuleName); + + // Load profiles to get count + var profilesData = ProfileService.LoadProfiles(); + + var telemetryEvent = new PowerDisplaySettingsTelemetryEvent + { + HotkeyEnabled = settings.Properties.ActivationShortcut?.IsValid() ?? false, + TrayIconEnabled = settings.Properties.ShowSystemTrayIcon, + MonitorCount = Monitors.Count, + ProfileCount = profilesData?.Profiles?.Count ?? 0, + }; + + PowerToysTelemetry.Log.WriteEvent(telemetryEvent); + } + catch (Exception ex) + { + Logger.LogError($"[Telemetry] Failed to send settings telemetry: {ex.Message}"); + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay/ViewModels/MainViewModel.cs b/src/modules/powerdisplay/PowerDisplay/ViewModels/MainViewModel.cs new file mode 100644 index 0000000000..d3a831a07d --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/ViewModels/MainViewModel.cs @@ -0,0 +1,428 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.Input; +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.UI; +using Microsoft.UI.Dispatching; +using Microsoft.UI.Windowing; +using PowerDisplay.Common.Drivers; +using PowerDisplay.Common.Drivers.DDC; +using PowerDisplay.Common.Models; +using PowerDisplay.Common.Services; +using PowerDisplay.Helpers; +using PowerDisplay.PowerDisplayXAML; + +namespace PowerDisplay.ViewModels; + +/// +/// Main ViewModel for the PowerDisplay application. +/// Split into partial classes for better maintainability: +/// - MainViewModel.cs: Core properties, construction, and disposal +/// - MainViewModel.Monitors.cs: Monitor discovery and management +/// - MainViewModel.Settings.cs: Settings UI synchronization and profiles +/// +[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicMethods)] +public partial class MainViewModel : INotifyPropertyChanged, IDisposable +{ + [LibraryImport("user32.dll", EntryPoint = "GetMonitorInfoW", StringMarshalling = StringMarshalling.Utf16)] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool GetMonitorInfo(IntPtr hMonitor, ref MonitorInfoEx lpmi); + + private readonly MonitorManager _monitorManager; + private readonly DispatcherQueue _dispatcherQueue; + private readonly CancellationTokenSource _cancellationTokenSource; + private readonly SettingsUtils _settingsUtils; + private readonly MonitorStateManager _stateManager; + private readonly DisplayChangeWatcher _displayChangeWatcher; + + private ObservableCollection _monitors; + private ObservableCollection _profiles; + private bool _isScanning; + private bool _isInitialized; + private bool _isLoading; + + /// + /// Event triggered when UI refresh is requested due to settings changes + /// + public event EventHandler? UIRefreshRequested; + + /// + /// Event triggered when initial monitor discovery is completed. + /// Used by MainWindow to know when data is ready for display. + /// + public event EventHandler? InitializationCompleted; + + public MainViewModel() + { + _dispatcherQueue = DispatcherQueue.GetForCurrentThread(); + _cancellationTokenSource = new CancellationTokenSource(); + _monitors = new ObservableCollection(); + _profiles = new ObservableCollection(); + _isScanning = true; + + // Initialize settings utils + _settingsUtils = SettingsUtils.Default; + _stateManager = new MonitorStateManager(); + + // Initialize the monitor manager + _monitorManager = new MonitorManager(); + + // Load profiles for quick apply feature + LoadProfiles(); + + // Load UI display settings (profile switcher, identify button, color temp switcher) + LoadUIDisplaySettings(); + + // Initialize display change watcher for auto-refresh on monitor plug/unplug + // Use MonitorRefreshDelay from settings to allow hardware to stabilize after plug/unplug + var settings = _settingsUtils.GetSettingsOrDefault(PowerDisplaySettings.ModuleName); + int delaySeconds = Math.Clamp(settings?.Properties?.MonitorRefreshDelay ?? 5, 1, 30); + _displayChangeWatcher = new DisplayChangeWatcher(_dispatcherQueue, TimeSpan.FromSeconds(delaySeconds)); + _displayChangeWatcher.DisplayChanged += OnDisplayChanged; + + // Start initial discovery + _ = InitializeAsync(_cancellationTokenSource.Token); + } + + public ObservableCollection Monitors + { + get => _monitors; + set + { + _monitors = value; + OnPropertyChanged(); + } + } + + public ObservableCollection Profiles + { + get => _profiles; + set + { + _profiles = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(HasProfiles)); + } + } + + public bool HasProfiles => Profiles.Count > 0; + + // UI display control properties - loaded from settings + private bool _showProfileSwitcher = true; + private bool _showIdentifyMonitorsButton = true; + + /// + /// Gets a value indicating whether to show the profile switcher button. + /// Combines settings value with HasProfiles check. + /// + public bool ShowProfileSwitcherButton => _showProfileSwitcher && HasProfiles; + + /// + /// Gets or sets a value indicating whether to show the profile switcher (from settings). + /// + public bool ShowProfileSwitcher + { + get => _showProfileSwitcher; + set + { + if (_showProfileSwitcher != value) + { + _showProfileSwitcher = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(ShowProfileSwitcherButton)); + } + } + } + + /// + /// Gets or sets a value indicating whether to show the identify monitors button. + /// + public bool ShowIdentifyMonitorsButton + { + get => _showIdentifyMonitorsButton; + set + { + if (_showIdentifyMonitorsButton != value) + { + _showIdentifyMonitorsButton = value; + OnPropertyChanged(); + } + } + } + + public bool IsScanning + { + get => _isScanning; + set + { + if (_isScanning != value) + { + _isScanning = value; + OnPropertyChanged(); + + // Dependent properties that change with IsScanning + OnPropertyChanged(nameof(HasMonitors)); + OnPropertyChanged(nameof(ShowNoMonitorsMessage)); + OnPropertyChanged(nameof(IsInteractionEnabled)); + } + } + } + + public bool HasMonitors => !IsScanning && Monitors.Count > 0; + + public bool ShowNoMonitorsMessage => !IsScanning && Monitors.Count == 0; + + public bool IsInitialized + { + get => _isInitialized; + private set + { + _isInitialized = value; + OnPropertyChanged(); + } + } + + public bool IsLoading + { + get => _isLoading; + private set + { + _isLoading = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(IsInteractionEnabled)); + } + } + + /// + /// Gets a value indicating whether user interaction is enabled (not loading or scanning). + /// + public bool IsInteractionEnabled => !IsLoading && !IsScanning; + + [RelayCommand] + private async Task RefreshAsync() => await RefreshMonitorsAsync(); + + [RelayCommand] + private unsafe void IdentifyMonitors() + { + try + { + // Get all display areas (virtual desktop regions) + var displayAreas = DisplayArea.FindAll(); + + // Get all monitor info from QueryDisplayConfig + var allDisplayInfo = DdcCiNative.GetAllMonitorDisplayInfo().Values.ToList(); + + // Build GDI name to MonitorNumber(s) mapping + // Note: In mirror mode, multiple monitors may share the same GdiDeviceName + var gdiToMonitorNumbers = allDisplayInfo + .Where(info => info.MonitorNumber > 0) + .GroupBy(info => info.GdiDeviceName, StringComparer.OrdinalIgnoreCase) + .ToDictionary( + g => g.Key, + g => g.Select(info => info.MonitorNumber).Distinct().OrderBy(n => n).ToList(), + StringComparer.OrdinalIgnoreCase); + + // For each DisplayArea, get its HMONITOR, then get GDI device name to find MonitorNumber(s) + int windowsCreated = 0; + for (int i = 0; i < displayAreas.Count; i++) + { + var displayArea = displayAreas[i]; + + // Convert DisplayId to HMONITOR + var hMonitor = Win32Interop.GetMonitorFromDisplayId(displayArea.DisplayId); + if (hMonitor == IntPtr.Zero) + { + continue; + } + + // Get GDI device name from HMONITOR + var monitorInfo = new MonitorInfoEx { CbSize = (uint)sizeof(MonitorInfoEx) }; + if (!GetMonitorInfo(hMonitor, ref monitorInfo)) + { + continue; + } + + var gdiDeviceName = monitorInfo.GetDeviceName(); + + // Look up MonitorNumber(s) by GDI device name + if (!gdiToMonitorNumbers.TryGetValue(gdiDeviceName, out var monitorNumbers) || monitorNumbers.Count == 0) + { + continue; + } + + // Format display text: single number for normal mode, "1|2" for mirror mode + var displayText = string.Join("|", monitorNumbers); + + // Create and position identify window + var identifyWindow = new IdentifyWindow(displayText); + identifyWindow.PositionOnDisplay(displayArea); + identifyWindow.Activate(); + windowsCreated++; + } + } + catch (Exception ex) + { + Logger.LogError($"Failed to identify monitors: {ex.Message}"); + } + } + + [RelayCommand] + private async Task ApplyProfile(PowerDisplayProfile? profile) + { + if (profile != null && profile.IsValid()) + { + await ApplyProfileAsync(profile.MonitorSettings); + } + } + + public event PropertyChangedEventHandler? PropertyChanged; + + protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + + public void Dispose() + { + // Cancel all async operations first + _cancellationTokenSource?.Cancel(); + + // Dispose each resource independently to ensure all get cleaned up + try + { + _displayChangeWatcher?.Dispose(); + } + catch + { + } + + // Dispose monitor view models + foreach (var vm in Monitors) + { + try + { + vm.Dispose(); + } + catch + { + } + } + + try + { + _monitorManager?.Dispose(); + } + catch + { + } + + try + { + _stateManager?.Dispose(); + } + catch + { + } + + try + { + _cancellationTokenSource?.Dispose(); + } + catch + { + } + + try + { + Monitors.Clear(); + } + catch + { + } + + GC.SuppressFinalize(this); + } + + /// + /// Load profiles from disk for quick apply feature + /// + private void LoadProfiles() + { + try + { + var profilesData = ProfileService.LoadProfiles(); + _profiles.Clear(); + foreach (var profile in profilesData.Profiles) + { + _profiles.Add(profile); + } + + OnPropertyChanged(nameof(HasProfiles)); + OnPropertyChanged(nameof(ShowProfileSwitcherButton)); + } + catch (Exception ex) + { + Logger.LogError($"[Profile] Failed to load profiles: {ex.Message}"); + } + } + + /// + /// Load UI display settings from settings file + /// + private void LoadUIDisplaySettings() + { + try + { + var settings = _settingsUtils.GetSettingsOrDefault(PowerDisplaySettings.ModuleName); + ShowProfileSwitcher = settings.Properties.ShowProfileSwitcher; + ShowIdentifyMonitorsButton = settings.Properties.ShowIdentifyMonitorsButton; + } + catch (Exception ex) + { + Logger.LogError($"[Settings] Failed to load UI display settings: {ex.Message}"); + } + } + + /// + /// Handles display configuration changes detected by the DisplayChangeWatcher. + /// The DisplayChangeWatcher already applies the configured delay (MonitorRefreshDelay) + /// to allow hardware to stabilize, so we can refresh immediately here. + /// + private async void OnDisplayChanged(object? sender, EventArgs e) + { + // Set scanning state to provide visual feedback + IsScanning = true; + + // Perform refresh - DisplayChangeWatcher has already waited for hardware to stabilize + await RefreshMonitorsAsync(skipScanningCheck: true); + } + + /// + /// Starts watching for display changes. Call after initialization is complete. + /// + public void StartDisplayWatching() + { + _displayChangeWatcher.Start(); + } + + /// + /// Stops watching for display changes. + /// + public void StopDisplayWatching() + { + _displayChangeWatcher.Stop(); + } +} diff --git a/src/modules/powerdisplay/PowerDisplay/ViewModels/MonitorViewModel.cs b/src/modules/powerdisplay/PowerDisplay/ViewModels/MonitorViewModel.cs new file mode 100644 index 0000000000..5629ebdacf --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/ViewModels/MonitorViewModel.cs @@ -0,0 +1,869 @@ +// 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.ComponentModel; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.Input; +using ManagedCommon; +using Microsoft.UI.Xaml; + +using PowerDisplay.Common.Models; +using PowerDisplay.Configuration; +using PowerDisplay.Helpers; +using Monitor = PowerDisplay.Common.Models.Monitor; + +namespace PowerDisplay.ViewModels; + +/// +/// ViewModel for individual monitor +/// +public partial class MonitorViewModel : INotifyPropertyChanged, IDisposable +{ + private readonly Monitor _monitor; + private readonly MonitorManager _monitorManager; + private readonly MainViewModel? _mainViewModel; + + private int _brightness; + private int _contrast; + private int _volume; + private bool _isAvailable; + + // Visibility settings (controlled by Settings UI) + private bool _showContrast; + private bool _showVolume; + private bool _showInputSource; + private bool _showRotation; + private bool _showPowerState; + + /// + /// Updates a property value directly without triggering hardware updates. + /// Used during initialization to update UI from saved state. + /// + internal void UpdatePropertySilently(string propertyName, int value) + { + switch (propertyName) + { + case nameof(Brightness): + _brightness = value; + OnPropertyChanged(nameof(Brightness)); + break; + case nameof(Contrast): + _contrast = value; + OnPropertyChanged(nameof(Contrast)); + OnPropertyChanged(nameof(ContrastPercent)); + break; + case nameof(Volume): + _volume = value; + OnPropertyChanged(nameof(Volume)); + break; + case nameof(ColorTemperature): + // Update underlying monitor model + _monitor.CurrentColorTemperature = value; + OnPropertyChanged(nameof(ColorTemperature)); + OnPropertyChanged(nameof(ColorTemperaturePresetName)); + break; + } + } + + /// + /// Apply brightness with hardware update and state persistence. + /// + /// Brightness value (0-100) + public async Task SetBrightnessAsync(int brightness) + { + brightness = Math.Clamp(brightness, MinBrightness, MaxBrightness); + + // Update UI state immediately + if (_brightness != brightness) + { + _brightness = brightness; + OnPropertyChanged(nameof(Brightness)); + } + + // Apply to hardware + await ApplyPropertyToHardwareAsync(nameof(Brightness), brightness, _monitorManager.SetBrightnessAsync); + } + + /// + /// Apply contrast with hardware update and state persistence. + /// + public async Task SetContrastAsync(int contrast) + { + contrast = Math.Clamp(contrast, MinContrast, MaxContrast); + + if (_contrast != contrast) + { + _contrast = contrast; + OnPropertyChanged(nameof(Contrast)); + OnPropertyChanged(nameof(ContrastPercent)); + } + + await ApplyPropertyToHardwareAsync(nameof(Contrast), contrast, _monitorManager.SetContrastAsync); + } + + /// + /// Apply volume with hardware update and state persistence. + /// + public async Task SetVolumeAsync(int volume) + { + volume = Math.Clamp(volume, MinVolume, MaxVolume); + + if (_volume != volume) + { + _volume = volume; + OnPropertyChanged(nameof(Volume)); + } + + await ApplyPropertyToHardwareAsync(nameof(Volume), volume, _monitorManager.SetVolumeAsync); + } + + /// + /// Unified method to apply color temperature with hardware update and state persistence. + /// Always immediate (no debouncing for discrete preset values). + /// + public async Task SetColorTemperatureAsync(int colorTemperature) + { + try + { + var result = await _monitorManager.SetColorTemperatureAsync(Id, colorTemperature); + + if (result.IsSuccess) + { + _monitor.CurrentColorTemperature = colorTemperature; + OnPropertyChanged(nameof(ColorTemperature)); + OnPropertyChanged(nameof(ColorTemperaturePresetName)); + + // Refresh the color presets list to update IsSelected checkmarks in UI + RefreshAvailableColorPresets(); + + _mainViewModel?.SaveMonitorSettingDirect(_monitor.Id, nameof(ColorTemperature), colorTemperature); + } + else + { + Logger.LogWarning($"[{Id}] Failed to set color temperature: {result.ErrorMessage}"); + } + } + catch (Exception ex) + { + Logger.LogError($"[{Id}] Exception setting color temperature: {ex.Message}"); + } + } + + /// + /// Generic method to apply a monitor property to hardware and persist state. + /// Consolidates common logic for brightness, contrast, and volume operations. + /// + /// Name of the property being set (for logging and state persistence) + /// Value to apply + /// Async function to call on MonitorManager + private async Task ApplyPropertyToHardwareAsync( + string propertyName, + int value, + Func> setAsyncFunc) + { + try + { + var result = await setAsyncFunc(Id, value, default); + + if (result.IsSuccess) + { + _mainViewModel?.SaveMonitorSettingDirect(_monitor.Id, propertyName, value); + } + else + { + Logger.LogWarning($"[{Id}] Failed to set {propertyName.ToLowerInvariant()}: {result.ErrorMessage}"); + } + } + catch (Exception ex) + { + Logger.LogError($"[{Id}] Exception setting {propertyName.ToLowerInvariant()}: {ex.Message}"); + } + } + + // Property to access IsInteractionEnabled from parent ViewModel + public bool IsInteractionEnabled => _mainViewModel?.IsInteractionEnabled ?? true; + + public MonitorViewModel(Monitor monitor, MonitorManager monitorManager, MainViewModel mainViewModel) + { + _monitor = monitor; + _monitorManager = monitorManager; + _mainViewModel = mainViewModel; + + // Subscribe to MainViewModel property changes to update IsInteractionEnabled + if (_mainViewModel != null) + { + _mainViewModel.PropertyChanged += OnMainViewModelPropertyChanged; + } + + // Subscribe to underlying Monitor property changes (e.g., Orientation updates in mirror mode) + _monitor.PropertyChanged += OnMonitorPropertyChanged; + + // Initialize Show properties based on hardware capabilities + _showContrast = monitor.SupportsContrast; + _showVolume = monitor.SupportsVolume; + _showInputSource = monitor.SupportsInputSource; + _showPowerState = monitor.SupportsPowerState; + _showColorTemperature = monitor.SupportsColorTemperature; + + // Initialize basic properties from monitor + _brightness = monitor.CurrentBrightness; + _contrast = monitor.CurrentContrast; + _volume = monitor.CurrentVolume; + _isAvailable = monitor.IsAvailable; + } + + public string Id => _monitor.Id; + + public string Name => _monitor.Name; + + /// + /// Gets the monitor number from the underlying monitor model (Windows DISPLAY number) + /// + public int MonitorNumber => _monitor.MonitorNumber; + + /// + /// Gets the display name - includes monitor number when multiple monitors exist. + /// Follows the same logic as Settings UI's MonitorInfo.DisplayName for consistency. + /// + public string DisplayName + { + get + { + var monitorCount = _mainViewModel?.Monitors?.Count ?? 0; + + // Show monitor number only when there are multiple monitors and MonitorNumber is valid + if (monitorCount > 1 && MonitorNumber > 0) + { + return $"{Name} {MonitorNumber}"; + } + + return Name; + } + } + + public string CommunicationMethod => _monitor.CommunicationMethod; + + public bool IsInternal => _monitor.CommunicationMethod == "WMI"; + + public string? CapabilitiesRaw => _monitor.CapabilitiesRaw; + + public VcpCapabilities? VcpCapabilitiesInfo => _monitor.VcpCapabilitiesInfo; + + /// + /// Gets the icon glyph based on communication method + /// WMI monitors (laptop internal displays) use laptop icon, others use external monitor icon + /// + public string MonitorIconGlyph => _monitor.CommunicationMethod?.Contains("WMI", StringComparison.OrdinalIgnoreCase) == true + ? AppConstants.UI.InternalMonitorGlyph // Laptop icon for WMI + : AppConstants.UI.ExternalMonitorGlyph; // External monitor icon for DDC/CI and others + + // Monitor property ranges + public int MinBrightness => _monitor.MinBrightness; + + public int MaxBrightness => _monitor.MaxBrightness; + + public int MinContrast => _monitor.MinContrast; + + public int MaxContrast => _monitor.MaxContrast; + + public int MinVolume => _monitor.MinVolume; + + public int MaxVolume => _monitor.MaxVolume; + + // Advanced control display logic + public bool HasAdvancedControls => ShowContrast || ShowVolume; + + public bool ShowContrast + { + get => _showContrast; + set + { + if (_showContrast != value) + { + _showContrast = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(HasAdvancedControls)); + } + } + } + + public bool ShowVolume + { + get => _showVolume; + set + { + if (_showVolume != value) + { + _showVolume = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(HasAdvancedControls)); + } + } + } + + public bool ShowInputSource + { + get => _showInputSource; + set + { + if (_showInputSource != value) + { + _showInputSource = value; + OnPropertyChanged(); + OnMoreButtonPropertiesChanged(); + } + } + } + + /// + /// Gets or sets a value indicating whether to show power state control in the More Button flyout. + /// + public bool ShowPowerState + { + get => _showPowerState && SupportsPowerState; + set + { + if (_showPowerState != value) + { + _showPowerState = value; + OnPropertyChanged(); + OnMoreButtonPropertiesChanged(); + } + } + } + + /// + /// Gets a value indicating whether the More Button should be visible. + /// Visible when at least one feature (InputSource or PowerState) is enabled. + /// + public bool ShowMoreButton => ShowInputSource || ShowPowerState; + + /// + /// Gets a value indicating whether to show separator after Input Source section. + /// Only shown when both InputSource and PowerState are visible. + /// + public bool ShowSeparatorAfterInputSource => ShowInputSource && ShowPowerState; + + /// + /// Notifies property changes for More Button related properties. + /// + private void OnMoreButtonPropertiesChanged() + { + OnPropertyChanged(nameof(ShowMoreButton)); + OnPropertyChanged(nameof(ShowSeparatorAfterInputSource)); + } + + /// + /// Gets or sets a value indicating whether to show rotation controls (controlled by Settings UI, default false). + /// + public bool ShowRotation + { + get => _showRotation; + set + { + if (_showRotation != value) + { + _showRotation = value; + OnPropertyChanged(); + } + } + } + + /// + /// Gets the current rotation/orientation of the monitor (0=normal, 1=90°, 2=180°, 3=270°) + /// + public int CurrentRotation => _monitor.Orientation; + + /// + /// Gets a value indicating whether the current rotation is 0° (normal/default). + /// + public bool IsRotation0 => CurrentRotation == 0; + + /// + /// Gets a value indicating whether the current rotation is 90° (rotated right). + /// + public bool IsRotation1 => CurrentRotation == 1; + + /// + /// Gets a value indicating whether the current rotation is 180° (inverted). + /// + public bool IsRotation2 => CurrentRotation == 2; + + /// + /// Gets a value indicating whether the current rotation is 270° (rotated left). + /// + public bool IsRotation3 => CurrentRotation == 3; + + /// + /// Set rotation/orientation for this monitor. + /// Note: MonitorManager.SetRotationAsync will refresh all monitors' orientations after success, + /// which triggers PropertyChanged through OnMonitorPropertyChanged - no manual notification needed here. + /// + /// Orientation: 0=normal, 1=90°, 2=180°, 3=270° + public async Task SetRotationAsync(int orientation) + { + // Validate orientation range (0=normal, 1=90°, 2=180°, 3=270°) + if (orientation < 0 || orientation > 3) + { + return; + } + + // If already at this orientation, do nothing + if (CurrentRotation == orientation) + { + return; + } + + try + { + var result = await _monitorManager.SetRotationAsync(Id, orientation); + + if (!result.IsSuccess) + { + Logger.LogWarning($"[{Id}] Failed to set rotation: {result.ErrorMessage}"); + } + } + catch (Exception ex) + { + Logger.LogError($"[{Id}] Exception setting rotation: {ex.Message}"); + } + } + + public int Brightness + { + get => _brightness; + set + { + if (_brightness != value) + { + _ = SetBrightnessAsync(value); + } + } + } + + /// + /// Gets color temperature VCP preset value (from VCP code 0x14). + /// Read-only in flyout UI - controlled via Settings UI. + /// Returns the raw VCP value (e.g., 0x05 for 6500K). + /// + public int ColorTemperature => _monitor.CurrentColorTemperature; + + /// + /// Gets human-readable color temperature preset name (e.g., "6500K", "sRGB") + /// + public string ColorTemperaturePresetName => _monitor.ColorTemperaturePresetName; + + /// + /// Gets a value indicating whether this monitor supports color temperature via VCP 0x14 + /// + public bool SupportsColorTemperature => _monitor.SupportsColorTemperature; + + private List? _availableColorPresets; + private bool _showColorTemperature; + + /// + /// Gets or sets a value indicating whether to show color temperature switcher (controlled by Settings UI, default false). + /// + public bool ShowColorTemperature + { + get => _showColorTemperature && SupportsColorTemperature; + set + { + if (_showColorTemperature != value) + { + _showColorTemperature = value; + OnPropertyChanged(); + } + } + } + + /// + /// Gets available color temperature presets for this monitor + /// + public List? AvailableColorPresets + { + get + { + if (_availableColorPresets == null && SupportsColorTemperature) + { + RefreshAvailableColorPresets(); + } + + return _availableColorPresets; + } + } + + /// + /// Standard MCCS color temperature presets (VCP 0x14 values) to use as fallback + /// when the monitor doesn't report discrete values in its capabilities string. + /// + private static readonly int[] StandardColorTemperaturePresets = { 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x08, 0x09, 0x0A, 0x0B }; + + /// + /// Refresh the list of available color temperature presets based on monitor capabilities + /// + private void RefreshAvailableColorPresets() + { + if (!SupportsColorTemperature) + { + _availableColorPresets = null; + return; + } + + IEnumerable presetValues; + var vcpInfo = VcpCapabilitiesInfo; + + // Try to get discrete values from capabilities string + if (vcpInfo != null && + vcpInfo.SupportedVcpCodes.TryGetValue(0x14, out var colorTempInfo) && + colorTempInfo.HasDiscreteValues && + colorTempInfo.SupportedValues.Count > 0) + { + // Use values from capabilities string + presetValues = colorTempInfo.SupportedValues; + } + else + { + // Fallback to standard MCCS presets when capabilities don't list discrete values + presetValues = StandardColorTemperaturePresets; + } + + _availableColorPresets = presetValues.Select(value => new ColorTemperatureItem + { + VcpValue = value, + DisplayName = Common.Utils.VcpNames.GetFormattedValueName(0x14, value), + IsSelected = value == _monitor.CurrentColorTemperature, + MonitorId = _monitor.Id, + }).ToList(); + + OnPropertyChanged(nameof(AvailableColorPresets)); + } + + /// + /// Gets a value indicating whether this monitor supports input source switching via VCP 0x60 + /// + public bool SupportsInputSource => _monitor.SupportsInputSource; + + /// + /// Gets current input source VCP value (from VCP code 0x60) + /// + public int CurrentInputSource => _monitor.CurrentInputSource; + + /// + /// Gets human-readable current input source name (e.g., "HDMI-1", "DisplayPort-1") + /// + public string CurrentInputSourceName => _monitor.InputSourceName; + + private List? _availableInputSources; + + /// + /// Gets available input sources for this monitor + /// + public List? AvailableInputSources + { + get + { + if (_availableInputSources == null && SupportsInputSource) + { + RefreshAvailableInputSources(); + } + + return _availableInputSources; + } + } + + /// + /// Refresh the list of available input sources based on monitor capabilities + /// + private void RefreshAvailableInputSources() + { + var supportedSources = _monitor.SupportedInputSources; + if (supportedSources == null || supportedSources.Count == 0) + { + _availableInputSources = null; + return; + } + + _availableInputSources = supportedSources.Select(value => new InputSourceItem + { + Value = value, + Name = Common.Utils.VcpNames.GetValueName(0x60, value) ?? $"Source 0x{value:X2}", + SelectionVisibility = value == _monitor.CurrentInputSource ? Visibility.Visible : Visibility.Collapsed, + MonitorId = _monitor.Id, + }).ToList(); + + OnPropertyChanged(nameof(AvailableInputSources)); + } + + /// + /// Set input source for this monitor + /// + public async Task SetInputSourceAsync(int inputSource) + { + try + { + var result = await _monitorManager.SetInputSourceAsync(Id, inputSource); + + if (result.IsSuccess) + { + OnPropertyChanged(nameof(CurrentInputSource)); + OnPropertyChanged(nameof(CurrentInputSourceName)); + RefreshAvailableInputSources(); + } + else + { + Logger.LogWarning($"[{Id}] Failed to set input source: {result.ErrorMessage}"); + } + } + catch (Exception ex) + { + Logger.LogError($"[{Id}] Exception setting input source: {ex.Message}"); + } + } + + /// + /// Command to set input source + /// + [RelayCommand] + private async Task SetInputSource(int? source) + { + if (source.HasValue) + { + await SetInputSourceAsync(source.Value); + } + } + + /// + /// Gets a value indicating whether this monitor supports power state control via VCP 0xD6 + /// + public bool SupportsPowerState => _monitor.SupportsPowerState; + + private List? _availablePowerStates; + + /// + /// Gets available power states for this monitor. + /// The current power state is shown as selected based on the monitor's actual state. + /// + public List? AvailablePowerStates + { + get + { + if (_availablePowerStates == null && SupportsPowerState) + { + RefreshAvailablePowerStates(); + } + + return _availablePowerStates; + } + } + + /// + /// Refresh the list of available power states based on monitor capabilities + /// + private void RefreshAvailablePowerStates() + { + var supportedStates = _monitor.SupportedPowerStates; + if (supportedStates == null || supportedStates.Count == 0) + { + _availablePowerStates = null; + return; + } + + _availablePowerStates = supportedStates.Select(value => new PowerStateItem + { + Value = value, + Name = Common.Utils.VcpNames.GetValueName(0xD6, value) ?? $"State 0x{value:X2}", + IsSelected = value == _monitor.CurrentPowerState, + MonitorId = _monitor.Id, + }).ToList(); + + OnPropertyChanged(nameof(AvailablePowerStates)); + } + + /// + /// Set power state for this monitor. + /// Note: Setting any state other than "On" will turn off the display. + /// + public async Task SetPowerStateAsync(int powerState) + { + try + { + var result = await _monitorManager.SetPowerStateAsync(Id, powerState); + + if (result.IsSuccess) + { + // Update the model's power state and refresh UI + _monitor.CurrentPowerState = powerState; + RefreshAvailablePowerStates(); + } + else + { + Logger.LogWarning($"[{Id}] Failed to set power state: {result.ErrorMessage}"); + } + } + catch (Exception ex) + { + Logger.LogError($"[{Id}] Exception setting power state: {ex.Message}"); + } + } + + /// + /// Command to set power state + /// + [RelayCommand] + private async Task SetPowerState(int? state) + { + if (state.HasValue) + { + await SetPowerStateAsync(state.Value); + } + } + + public int Contrast + { + get => _contrast; + set + { + if (_contrast != value) + { + _ = SetContrastAsync(value); + } + } + } + + public int Volume + { + get => _volume; + set + { + if (_volume != value) + { + _ = SetVolumeAsync(value); + } + } + } + + public bool IsAvailable + { + get => _isAvailable; + set + { + _isAvailable = value; + OnPropertyChanged(); + } + } + + [RelayCommand] + private void SetBrightness(int? brightness) + { + if (brightness.HasValue) + { + Brightness = brightness.Value; + } + } + + [RelayCommand] + private void SetContrast(int? contrast) + { + if (contrast.HasValue) + { + Contrast = contrast.Value; + } + } + + [RelayCommand] + private void SetVolume(int? volume) + { + if (volume.HasValue) + { + Volume = volume.Value; + } + } + + public int ContrastPercent + { + get => MapToPercent(_contrast, MinContrast, MaxContrast); + set + { + var actualValue = MapFromPercent(value, MinContrast, MaxContrast); + Contrast = actualValue; + } + } + + // Mapping functions for percentage conversion + private int MapToPercent(int value, int min, int max) + { + if (max <= min) + { + return 0; + } + + return (int)Math.Round((value - min) * 100.0 / (max - min)); + } + + private int MapFromPercent(int percent, int min, int max) + { + if (max <= min) + { + return min; + } + + percent = Math.Clamp(percent, 0, 100); + return min + (int)Math.Round(percent * (max - min) / 100.0); + } + + public event PropertyChangedEventHandler? PropertyChanged; + + protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + + private void OnMainViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(MainViewModel.IsInteractionEnabled)) + { + OnPropertyChanged(nameof(IsInteractionEnabled)); + } + else if (e.PropertyName == nameof(MainViewModel.HasMonitors)) + { + // Monitor count changed, update display name to show/hide number suffix + OnPropertyChanged(nameof(DisplayName)); + } + } + + private void OnMonitorPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + // Forward Orientation changes from underlying Monitor to ViewModel properties + // This is important for mirror mode where MonitorManager.RefreshAllOrientations() + // updates multiple monitors sharing the same GdiDeviceName + if (e.PropertyName == nameof(Monitor.Orientation)) + { + OnPropertyChanged(nameof(CurrentRotation)); + OnPropertyChanged(nameof(IsRotation0)); + OnPropertyChanged(nameof(IsRotation1)); + OnPropertyChanged(nameof(IsRotation2)); + OnPropertyChanged(nameof(IsRotation3)); + } + } + + public void Dispose() + { + // Unsubscribe from MainViewModel events + if (_mainViewModel != null) + { + _mainViewModel.PropertyChanged -= OnMainViewModelPropertyChanged; + } + + // Unsubscribe from underlying Monitor events + _monitor.PropertyChanged -= OnMonitorPropertyChanged; + + GC.SuppressFinalize(this); + } +} diff --git a/src/modules/powerdisplay/PowerDisplay/ViewModels/PowerStateItem.cs b/src/modules/powerdisplay/PowerDisplay/ViewModels/PowerStateItem.cs new file mode 100644 index 0000000000..6be02e8d7f --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/ViewModels/PowerStateItem.cs @@ -0,0 +1,45 @@ +// 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 Microsoft.UI.Xaml; + +namespace PowerDisplay.ViewModels; + +/// +/// Represents a power state option for display in UI. +/// VCP 0xD6 values: 0x01=On, 0x02=Standby, 0x03=Suspend, 0x04=Off(DPM), 0x05=Off(Hard) +/// +public class PowerStateItem +{ + /// + /// VCP power mode value representing On state + /// + public const int PowerStateOn = 0x01; + + /// + /// VCP value for this power state + /// + public int Value { get; set; } + + /// + /// Human-readable name (e.g., "On", "Standby", "Off (DPM)") + /// + public string Name { get; set; } = string.Empty; + + /// + /// Gets or sets whether this power state is currently selected. + /// Set based on monitor's actual power state during list creation. + /// + public bool IsSelected { get; set; } + + /// + /// Visibility of selection indicator (Visible when IsSelected is true) + /// + public Visibility SelectionVisibility => IsSelected ? Visibility.Visible : Visibility.Collapsed; + + /// + /// Monitor ID for direct lookup (Flyout popup is not in visual tree) + /// + public string MonitorId { get; set; } = string.Empty; +} diff --git a/src/modules/powerdisplay/PowerDisplay/app.manifest b/src/modules/powerdisplay/PowerDisplay/app.manifest new file mode 100644 index 0000000000..8a5a071870 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/app.manifest @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + true/PM + PerMonitorV2, PerMonitor + + + \ No newline at end of file diff --git a/src/modules/powerdisplay/PowerDisplayModuleInterface/PowerDisplayModuleInterface.rc b/src/modules/powerdisplay/PowerDisplayModuleInterface/PowerDisplayModuleInterface.rc new file mode 100644 index 0000000000..2f225053a0 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplayModuleInterface/PowerDisplayModuleInterface.rc @@ -0,0 +1,97 @@ +// Microsoft Visual C++ generated resource script. +// +#include +#include "resource.h" +#include "../../../common/version/version.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +1 VERSIONINFO +FILEVERSION FILE_VERSION +PRODUCTVERSION PRODUCT_VERSION +FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG +FILEFLAGS VS_FF_DEBUG +#else +FILEFLAGS 0x0L +#endif +FILEOS VOS_NT_WINDOWS32 +FILETYPE VFT_DLL +FILESUBTYPE VFT2_UNKNOWN +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904b0" // US English (0x0409), Unicode (0x04B0) charset + BEGIN + VALUE "CompanyName", COMPANY_NAME + VALUE "FileDescription", FILE_DESCRIPTION + VALUE "FileVersion", FILE_VERSION_STRING + VALUE "InternalName", INTERNAL_NAME + VALUE "LegalCopyright", COPYRIGHT_NOTE + VALUE "OriginalFilename", ORIGINAL_FILENAME + VALUE "ProductName", PRODUCT_NAME + VALUE "ProductVersion", PRODUCT_VERSION_STRING + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1200 // US English (0x0409), Unicode (1200) charset + END +END + + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US +#pragma code_page(1252) + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED + diff --git a/src/modules/powerdisplay/PowerDisplayModuleInterface/PowerDisplayModuleInterface.vcxproj b/src/modules/powerdisplay/PowerDisplayModuleInterface/PowerDisplayModuleInterface.vcxproj new file mode 100644 index 0000000000..6c68d0e291 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplayModuleInterface/PowerDisplayModuleInterface.vcxproj @@ -0,0 +1,133 @@ + + + + + + Debug + ARM64 + + + Release + ARM64 + + + Debug + x64 + + + Release + x64 + + + + 16.0 + {D1234567-8901-2345-6789-ABCDEF012345} + Win32Proj + PowerDisplayModuleInterface + + + + DynamicLibrary + true + v143 + Unicode + + + DynamicLibrary + false + v143 + true + Unicode + + + + + + + + + + + + ..\..\..\..\$(Platform)\$(Configuration)\ + $(Platform)\$(Configuration)\PowerDisplayModuleInterface\ + PowerToys.PowerDisplayModuleInterface + + + + Level3 + true + _DEBUG;POWERDISPLAYMODULEINTERFACE_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions) + true + ..\..\..\common\inc;..\..\..\common\Telemetry;..\..\;..\..\..\;%(AdditionalIncludeDirectories) + + + Windows + true + false + Shlwapi.lib;Rpcrt4.lib;$(CoreLibraryDependencies);%(AdditionalDependencies) + + + + + Level3 + true + true + true + NDEBUG;POWERDISPLAYMODULEINTERFACE_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions) + true + ..\..\..\common\inc;..\..\..\common\Telemetry;..\..\;..\..\..\;%(AdditionalIncludeDirectories) + + + Windows + true + true + true + false + Shlwapi.lib;Rpcrt4.lib;$(CoreLibraryDependencies);%(AdditionalDependencies) + + + + + + + + + + + + + Create + + + + + + + {d9b8fc84-322a-4f9f-bbb9-20915c47ddfd} + + + {6955446d-23f7-4023-9bb3-8657f904af99} + + + + + + + + + + + + + + + + + This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. + + + + + + \ No newline at end of file diff --git a/src/modules/powerdisplay/PowerDisplayModuleInterface/PowerDisplayModuleInterface.vcxproj.filters b/src/modules/powerdisplay/PowerDisplayModuleInterface/PowerDisplayModuleInterface.vcxproj.filters new file mode 100644 index 0000000000..0872553d99 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplayModuleInterface/PowerDisplayModuleInterface.vcxproj.filters @@ -0,0 +1,53 @@ + + + + + {4FC737F1-C7A5-4376-A066-2A32D752A2FF} + cpp;c;cc;cxx;c++;cppm;ixx;def;odl;idl;hpj;bat;asm;asmx + + + {93995380-89BD-4b04-88EB-625FBE52EBFB} + h;hh;hpp;hxx;h++;hm;inl;inc;ipp;xsd + + + {67DA6AB6-F800-4c08-8B7A-83BB121AAD01} + rc;ico;cur;bmp;dlg;rc2;rct;bin;rgs;gif;jpg;jpeg;jpe;resx;tiff;tif;png;wav;mfcribbon-ms + + + + + Header Files + + + Header Files + + + Header Files + + + Header Files + + + + + Source Files + + + Source Files + + + Source Files + + + + + Resource Files + + + + + + + + + \ No newline at end of file diff --git a/src/modules/powerdisplay/PowerDisplayModuleInterface/PowerDisplayProcessManager.cpp b/src/modules/powerdisplay/PowerDisplayModuleInterface/PowerDisplayProcessManager.cpp new file mode 100644 index 0000000000..6f35629d3b --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplayModuleInterface/PowerDisplayProcessManager.cpp @@ -0,0 +1,282 @@ +// 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. + +#include "pch.h" +#include "PowerDisplayProcessManager.h" + +#include +#include +#include +#include + +namespace +{ + std::optional get_pipe_name(const std::wstring& prefix) + { + UUID temp_uuid; + wchar_t* uuid_chars = nullptr; + if (UuidCreate(&temp_uuid) == RPC_S_UUID_NO_ADDRESS) + { + const auto val = get_last_error_message(GetLastError()); + Logger::error(L"UuidCreate cannot create guid. {}", val.has_value() ? val.value() : L""); + return std::nullopt; + } + else if (UuidToString(&temp_uuid, reinterpret_cast(&uuid_chars)) != RPC_S_OK) + { + const auto val = get_last_error_message(GetLastError()); + Logger::error(L"UuidToString cannot convert to string. {}", val.has_value() ? val.value() : L""); + return std::nullopt; + } + + const auto pipe_name = std::format(L"{}{}", prefix, std::wstring(uuid_chars)); + RpcStringFree(reinterpret_cast(&uuid_chars)); + + return pipe_name; + } +} + +void PowerDisplayProcessManager::start() +{ + m_enabled = true; + submit_task([this]() { refresh(); }); +} + +void PowerDisplayProcessManager::stop() +{ + m_enabled = false; + submit_task([this]() { refresh(); }); +} + +void PowerDisplayProcessManager::send_message(const std::wstring& message_type, const std::wstring& message_arg) +{ + submit_task([this, message_type, message_arg] { + // Ensure process is running before sending message + if (!is_process_running() && m_enabled) + { + refresh(); + } + send_named_pipe_message(message_type, message_arg); + }); +} + +void PowerDisplayProcessManager::bring_to_front() +{ + submit_task([this] { + if (!is_process_running()) + { + return; + } + + const auto enum_windows = [](HWND hwnd, LPARAM param) -> BOOL { + const auto process_handle = reinterpret_cast(param); + DWORD window_process_id = 0; + + GetWindowThreadProcessId(hwnd, &window_process_id); + if (GetProcessId(process_handle) == window_process_id) + { + SetForegroundWindow(hwnd); + return FALSE; + } + return TRUE; + }; + + EnumWindows(enum_windows, reinterpret_cast(m_hProcess)); + }); +} + +bool PowerDisplayProcessManager::is_running() const +{ + return is_process_running(); +} + +void PowerDisplayProcessManager::submit_task(std::function task) +{ + m_thread_executor.submit(OnThreadExecutor::task_t{ task }); +} + +bool PowerDisplayProcessManager::is_process_running() const +{ + return m_hProcess != 0 && WaitForSingleObject(m_hProcess, 0) == WAIT_TIMEOUT; +} + +void PowerDisplayProcessManager::terminate_process() +{ + if (m_hProcess != 0) + { + TerminateProcess(m_hProcess, 1); + CloseHandle(m_hProcess); + m_hProcess = 0; + } +} + +HRESULT PowerDisplayProcessManager::start_process(const std::wstring& pipe_name) +{ + const unsigned long powertoys_pid = GetCurrentProcessId(); + + // Pass both PID and pipe name as arguments + const auto executable_args = std::format(L"{} {}", std::to_wstring(powertoys_pid), pipe_name); + + SHELLEXECUTEINFOW sei{ sizeof(sei) }; + sei.fMask = { SEE_MASK_NOCLOSEPROCESS | SEE_MASK_FLAG_NO_UI }; + sei.lpFile = L"WinUI3Apps\\PowerToys.PowerDisplay.exe"; + sei.nShow = SW_SHOWNORMAL; + sei.lpParameters = executable_args.data(); + if (ShellExecuteExW(&sei)) + { + Logger::trace("Successfully started PowerDisplay process"); + terminate_process(); + m_hProcess = sei.hProcess; + return S_OK; + } + else + { + Logger::error(L"PowerDisplay process failed to start. {}", get_last_error_or_default(GetLastError())); + return E_FAIL; + } +} + +HRESULT PowerDisplayProcessManager::start_named_pipe_server(const std::wstring& pipe_name) +{ + m_write_pipe = nullptr; + + const constexpr DWORD BUFSIZE = 4096 * 4; + + const auto full_pipe_name = std::format(L"\\\\.\\pipe\\{}", pipe_name); + + const auto hPipe = CreateNamedPipe( + full_pipe_name.c_str(), // pipe name + PIPE_ACCESS_OUTBOUND | // write access + FILE_FLAG_OVERLAPPED, // overlapped mode + PIPE_TYPE_MESSAGE | // message type pipe + PIPE_READMODE_MESSAGE | // message-read mode + PIPE_WAIT, // blocking mode + 1, // max. instances + BUFSIZE, // output buffer size + 0, // input buffer size + 0, // client time-out + NULL); // default security attribute + + if (hPipe == NULL || hPipe == INVALID_HANDLE_VALUE) + { + Logger::error(L"Error creating handle for named pipe"); + return E_FAIL; + } + + // Create overlapped event to wait for client to connect to pipe. + OVERLAPPED overlapped = { 0 }; + overlapped.hEvent = CreateEvent(nullptr, true, false, nullptr); + if (!overlapped.hEvent) + { + Logger::error(L"Error creating overlapped event for named pipe"); + CloseHandle(hPipe); + return E_FAIL; + } + + const auto clean_up_and_fail = [&]() { + CloseHandle(overlapped.hEvent); + CloseHandle(hPipe); + return E_FAIL; + }; + + if (!ConnectNamedPipe(hPipe, &overlapped)) + { + const auto lastError = GetLastError(); + + if (lastError != ERROR_IO_PENDING && lastError != ERROR_PIPE_CONNECTED) + { + Logger::error(L"Error connecting to named pipe"); + return clean_up_and_fail(); + } + } + + // Wait for client. + const constexpr DWORD client_timeout_millis = 5000; + switch (WaitForSingleObject(overlapped.hEvent, client_timeout_millis)) + { + case WAIT_OBJECT_0: + { + DWORD bytes_transferred = 0; + if (GetOverlappedResult(hPipe, &overlapped, &bytes_transferred, FALSE)) + { + CloseHandle(overlapped.hEvent); + m_write_pipe = std::make_unique(hPipe); + + Logger::trace(L"PowerDisplay successfully connected to named pipe"); + + return S_OK; + } + else + { + Logger::error(L"Error waiting for PowerDisplay to connect to named pipe"); + return clean_up_and_fail(); + } + } + + case WAIT_TIMEOUT: + case WAIT_FAILED: + default: + Logger::error(L"Error waiting for PowerDisplay to connect to named pipe"); + return clean_up_and_fail(); + } +} + +void PowerDisplayProcessManager::refresh() +{ + if (m_enabled == is_process_running()) + { + return; + } + + if (m_enabled) + { + Logger::trace(L"Starting PowerDisplay process"); + + const auto pipe_name = get_pipe_name(L"powertoys_power_display_"); + + if (!pipe_name) + { + return; + } + + if (start_process(pipe_name.value()) != S_OK) + { + return; + } + + if (start_named_pipe_server(pipe_name.value()) != S_OK) + { + Logger::error(L"Named pipe initialization failed; terminating PowerDisplay process"); + terminate_process(); + } + } + else + { + Logger::trace(L"Exiting PowerDisplay process"); + + send_named_pipe_message(CommonSharedConstants::POWER_DISPLAY_TERMINATE_APP_MESSAGE); + WaitForSingleObject(m_hProcess, 5000); + + if (is_process_running()) + { + Logger::error(L"PowerDisplay process failed to gracefully exit; terminating"); + } + else + { + Logger::trace(L"PowerDisplay process successfully exited"); + } + + terminate_process(); + } +} + +void PowerDisplayProcessManager::send_named_pipe_message(const std::wstring& message_type, const std::wstring& message_arg) +{ + if (m_write_pipe) + { + const auto message = message_arg.empty() ? std::format(L"{}\r\n", message_type) : std::format(L"{} {}\r\n", message_type, message_arg); + + const CString file_name(message.c_str()); + m_write_pipe->Write(file_name, file_name.GetLength() * sizeof(TCHAR)); + } +} diff --git a/src/modules/powerdisplay/PowerDisplayModuleInterface/PowerDisplayProcessManager.h b/src/modules/powerdisplay/PowerDisplayModuleInterface/PowerDisplayProcessManager.h new file mode 100644 index 0000000000..98e31918b3 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplayModuleInterface/PowerDisplayProcessManager.h @@ -0,0 +1,65 @@ +// 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. + +#pragma once +#include "pch.h" +#include +#include +#include +#include +#include +#include + +/// +/// Manages the PowerDisplay.exe process and Named Pipe communication. +/// Based on AdvancedPasteProcessManager pattern. +/// +class PowerDisplayProcessManager +{ +public: + PowerDisplayProcessManager() = default; + PowerDisplayProcessManager(const PowerDisplayProcessManager&) = delete; + PowerDisplayProcessManager& operator=(const PowerDisplayProcessManager&) = delete; + + /// + /// Enable the module - starts the PowerDisplay.exe process. + /// + void start(); + + /// + /// Disable the module - terminates the PowerDisplay.exe process. + /// + void stop(); + + /// + /// Send a message to PowerDisplay.exe via Named Pipe. + /// + /// The message type (e.g., "Toggle", "ApplyProfile") + /// Optional message argument + void send_message(const std::wstring& message_type, const std::wstring& message_arg = L""); + + /// + /// Bring the PowerDisplay window to the foreground. + /// + void bring_to_front(); + + /// + /// Check if PowerDisplay.exe process is running. + /// + bool is_running() const; + +private: + void submit_task(std::function task); + bool is_process_running() const; + void terminate_process(); + HRESULT start_process(const std::wstring& pipe_name); + HRESULT start_named_pipe_server(const std::wstring& pipe_name); + void refresh(); + void send_named_pipe_message(const std::wstring& message_type, const std::wstring& message_arg = L""); + + OnThreadExecutor m_thread_executor; // all internal operations are done on background thread with task queue + std::atomic m_enabled = false; // written on main thread, read on background thread + HANDLE m_hProcess = 0; + std::unique_ptr m_write_pipe; +}; diff --git a/src/modules/powerdisplay/PowerDisplayModuleInterface/Trace.cpp b/src/modules/powerdisplay/PowerDisplayModuleInterface/Trace.cpp new file mode 100644 index 0000000000..3ac410724b --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplayModuleInterface/Trace.cpp @@ -0,0 +1,32 @@ +#include "pch.h" +#include "trace.h" + +#include + +TRACELOGGING_DEFINE_PROVIDER( + g_hProvider, + "Microsoft.PowerToys", + // {38e8889b-9731-53f5-e901-e8a7c1753074} + (0x38e8889b, 0x9731, 0x53f5, 0xe9, 0x01, 0xe8, 0xa7, 0xc1, 0x75, 0x30, 0x74), + TraceLoggingOptionProjectTelemetry()); + +// Log if the user has enabled or disabled the app +void Trace::EnablePowerDisplay(_In_ bool enabled) noexcept +{ + TraceLoggingWriteWrapper( + g_hProvider, + "PowerDisplay_EnablePowerDisplay", + ProjectTelemetryPrivacyDataTag(ProjectTelemetryTag_ProductAndServicePerformance), + TraceLoggingKeyword(PROJECT_KEYWORD_MEASURE), + TraceLoggingBoolean(enabled, "Enabled")); +} + +// Log that the user tried to activate the app +void Trace::ActivatePowerDisplay() noexcept +{ + TraceLoggingWriteWrapper( + g_hProvider, + "PowerDisplay_Activate", + ProjectTelemetryPrivacyDataTag(ProjectTelemetryTag_ProductAndServicePerformance), + TraceLoggingKeyword(PROJECT_KEYWORD_MEASURE)); +} diff --git a/src/modules/powerdisplay/PowerDisplayModuleInterface/Trace.h b/src/modules/powerdisplay/PowerDisplayModuleInterface/Trace.h new file mode 100644 index 0000000000..c650cfb346 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplayModuleInterface/Trace.h @@ -0,0 +1,13 @@ +#pragma once + +#include + +class Trace : public telemetry::TraceBase +{ +public: + // Log if the user has enabled or disabled the app + static void EnablePowerDisplay(const bool enabled) noexcept; + + // Log that the user tried to activate the app + static void ActivatePowerDisplay() noexcept; +}; diff --git a/src/modules/powerdisplay/PowerDisplayModuleInterface/dllmain.cpp b/src/modules/powerdisplay/PowerDisplayModuleInterface/dllmain.cpp new file mode 100644 index 0000000000..871a8797ef --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplayModuleInterface/dllmain.cpp @@ -0,0 +1,245 @@ +// dllmain.cpp : Defines the entry point for the DLL Application. +#include "pch.h" +#include +#include +#include "trace.h" +#include "PowerDisplayProcessManager.h" +#include +#include +#include +#include +#include + +#include "resource.h" + +extern "C" IMAGE_DOS_HEADER __ImageBase; + +BOOL APIENTRY DllMain(HMODULE /*hModule*/, DWORD ul_reason_for_call, LPVOID /*lpReserved*/) +{ + switch (ul_reason_for_call) + { + case DLL_PROCESS_ATTACH: + Trace::RegisterProvider(); + break; + case DLL_THREAD_ATTACH: + case DLL_THREAD_DETACH: + break; + case DLL_PROCESS_DETACH: + Trace::UnregisterProvider(); + break; + } + return TRUE; +} + +const static wchar_t* MODULE_NAME = L"PowerDisplay"; +const static wchar_t* MODULE_DESC = L"A utility to manage display brightness and color temperature across multiple monitors."; + +class PowerDisplayModule : public PowertoyModuleIface +{ +private: + bool m_enabled = false; + + // Process manager handles Named Pipe communication and process lifecycle + PowerDisplayProcessManager m_processManager; + + // Windows Events for Settings UI triggered events (these are still needed) + // Note: These events are created on-demand by EventHelper.SignalEvent() in Settings UI + // and NativeEventWaiter.WaitForEventLoop() in PowerDisplay.exe. + HANDLE m_hRefreshEvent = nullptr; + HANDLE m_hSendSettingsTelemetryEvent = nullptr; + +public: + PowerDisplayModule() + { + LoggerHelpers::init_logger(MODULE_NAME, L"ModuleInterface", LogSettings::powerDisplayLoggerName); + Logger::info("Power Display module is constructing"); + + // Create Windows Events for Settings UI triggered operations + // These events are signaled by Settings UI, not by module DLL + Logger::trace(L"Creating Windows Events for Settings UI IPC..."); + m_hRefreshEvent = CreateDefaultEvent(CommonSharedConstants::REFRESH_POWER_DISPLAY_MONITORS_EVENT); + Logger::trace(L"Created REFRESH_MONITORS_EVENT: handle={}", reinterpret_cast(m_hRefreshEvent)); + m_hSendSettingsTelemetryEvent = CreateDefaultEvent(CommonSharedConstants::POWER_DISPLAY_SEND_SETTINGS_TELEMETRY_EVENT); + Logger::trace(L"Created SEND_SETTINGS_TELEMETRY_EVENT: handle={}", reinterpret_cast(m_hSendSettingsTelemetryEvent)); + + if (!m_hRefreshEvent || !m_hSendSettingsTelemetryEvent) + { + Logger::error(L"Failed to create one or more event handles: Refresh={}, SettingsTelemetry={}", + reinterpret_cast(m_hRefreshEvent), + reinterpret_cast(m_hSendSettingsTelemetryEvent)); + } + else + { + Logger::info(L"All Windows Events created successfully"); + } + } + + ~PowerDisplayModule() + { + if (m_enabled) + { + disable(); + } + + // Clean up event handles + if (m_hRefreshEvent) + { + CloseHandle(m_hRefreshEvent); + m_hRefreshEvent = nullptr; + } + if (m_hSendSettingsTelemetryEvent) + { + CloseHandle(m_hSendSettingsTelemetryEvent); + m_hSendSettingsTelemetryEvent = nullptr; + } + } + + virtual void destroy() override + { + Logger::trace("PowerDisplay::destroy()"); + delete this; + } + + virtual const wchar_t* get_name() override + { + return MODULE_NAME; + } + + virtual const wchar_t* get_key() override + { + return MODULE_NAME; + } + + virtual powertoys_gpo::gpo_rule_configured_t gpo_policy_enabled_configuration() override + { + return powertoys_gpo::getConfiguredPowerDisplayEnabledValue(); + } + + virtual bool get_config(wchar_t* buffer, int* buffer_size) override + { + HINSTANCE hinstance = reinterpret_cast(&__ImageBase); + + PowerToysSettings::Settings settings(hinstance, get_name()); + settings.set_description(MODULE_DESC); + + return settings.serialize_to_buffer(buffer, buffer_size); + } + + virtual void call_custom_action(const wchar_t* action) override + { + try + { + PowerToysSettings::CustomActionObject action_object = + PowerToysSettings::CustomActionObject::from_json_string(action); + + if (action_object.get_name() == L"Launch") + { + Logger::trace(L"Launch action received"); + + // Send Toggle message via Named Pipe (will start process if needed) + m_processManager.send_message(CommonSharedConstants::POWER_DISPLAY_TOGGLE_MESSAGE); + Trace::ActivatePowerDisplay(); + } + else if (action_object.get_name() == L"RefreshMonitors") + { + Logger::trace(L"RefreshMonitors action received, signaling refresh event"); + if (m_hRefreshEvent) + { + SetEvent(m_hRefreshEvent); + } + else + { + Logger::warn(L"Refresh event handle is null"); + } + } + else if (action_object.get_name() == L"ApplyProfile") + { + Logger::trace(L"ApplyProfile action received"); + + // Get the profile name from the action value + std::wstring profileName = action_object.get_value(); + Logger::trace(L"ApplyProfile: profile name = '{}'", profileName); + + // Send ApplyProfile message with profile name via Named Pipe + m_processManager.send_message(CommonSharedConstants::POWER_DISPLAY_APPLY_PROFILE_MESSAGE, profileName); + } + } + catch (std::exception&) + { + Logger::error(L"Failed to parse action. {}", action); + } + } + + virtual void set_config(const wchar_t* /*config*/) override + { + // Settings changes are handled via dedicated Windows Events: + // - HotkeyUpdatedPowerDisplayEvent: triggered by Settings UI when activation shortcut changes + // - SettingsUpdatedPowerDisplayEvent: triggered for tray icon visibility changes + // PowerDisplay.exe reads settings directly from file when these events are signaled. + } + + virtual void enable() override + { + Logger::info(L"enable: PowerDisplay module is being enabled"); + m_enabled = true; + Trace::EnablePowerDisplay(true); + + // Start the process manager (launches PowerDisplay.exe with Named Pipe) + m_processManager.start(); + + Logger::info(L"enable: PowerDisplay module enabled successfully"); + } + + virtual void disable() override + { + Logger::trace(L"PowerDisplay::disable()"); + + if (m_enabled) + { + // Stop the process manager (sends terminate message and waits for exit) + m_processManager.stop(); + } + + m_enabled = false; + Trace::EnablePowerDisplay(false); + } + + virtual bool is_enabled() override + { + return m_enabled; + } + + // NOTE: Hotkey handling is done in-process by PowerDisplay.exe using RegisterHotKey, + // similar to CmdPal pattern. This avoids IPC timing issues where Deactivated event + // fires before the Toggle event arrives from Runner. + virtual bool on_hotkey(size_t /*hotkeyId*/) override + { + // PowerDisplay handles hotkeys in-process, not via Runner IPC + return false; + } + + virtual size_t get_hotkeys(Hotkey* /*hotkeys*/, size_t /*buffer_size*/) override + { + // PowerDisplay handles hotkeys in-process, not via Runner + // Return 0 to tell Runner we don't want any hotkeys registered + return 0; + } + + virtual void send_settings_telemetry() override + { + Logger::trace(L"send_settings_telemetry: Signaling settings telemetry event"); + if (m_hSendSettingsTelemetryEvent) + { + SetEvent(m_hSendSettingsTelemetryEvent); + } + else + { + Logger::warn(L"send_settings_telemetry: Event handle is null"); + } + } +}; + +extern "C" __declspec(dllexport) PowertoyModuleIface* __cdecl powertoy_create() +{ + return new PowerDisplayModule(); +} diff --git a/src/modules/powerdisplay/PowerDisplayModuleInterface/packages.config b/src/modules/powerdisplay/PowerDisplayModuleInterface/packages.config new file mode 100644 index 0000000000..ff4b059648 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplayModuleInterface/packages.config @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src/modules/powerdisplay/PowerDisplayModuleInterface/pch.cpp b/src/modules/powerdisplay/PowerDisplayModuleInterface/pch.cpp new file mode 100644 index 0000000000..1d9f38c57d --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplayModuleInterface/pch.cpp @@ -0,0 +1 @@ +#include "pch.h" diff --git a/src/modules/powerdisplay/PowerDisplayModuleInterface/pch.h b/src/modules/powerdisplay/PowerDisplayModuleInterface/pch.h new file mode 100644 index 0000000000..9e02b6c9ce --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplayModuleInterface/pch.h @@ -0,0 +1,13 @@ +#pragma once + +#define WIN32_LEAN_AND_MEAN +#include +#include +#include +#include + +#include + +#include +#include +#include diff --git a/src/modules/powerdisplay/PowerDisplayModuleInterface/resource.h b/src/modules/powerdisplay/PowerDisplayModuleInterface/resource.h new file mode 100644 index 0000000000..86220c10fa --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplayModuleInterface/resource.h @@ -0,0 +1,13 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by PowerDisplayExt.rc + +////////////////////////////// +// Non-localizable + +#define FILE_DESCRIPTION "PowerToys PowerDisplay Module" +#define INTERNAL_NAME "PowerToys.PowerDisplay" +#define ORIGINAL_FILENAME "PowerToys.PowerDisplay.dll" + +// Non-localizable +////////////////////////////// diff --git a/src/runner/main.cpp b/src/runner/main.cpp index d8fdcbdb04..973cee4ba5 100644 --- a/src/runner/main.cpp +++ b/src/runner/main.cpp @@ -286,6 +286,7 @@ int runner(bool isProcessElevated, bool openSettings, std::string settingsWindow L"PowerToys.CmdPalModuleInterface.dll", L"PowerToys.ZoomItModuleInterface.dll", L"PowerToys.LightSwitchModuleInterface.dll", + L"PowerToys.PowerDisplayModuleInterface.dll", }; for (auto moduleSubdir : knownModules) diff --git a/src/runner/settings_window.cpp b/src/runner/settings_window.cpp index 1cf8bac330..022ad9d76c 100644 --- a/src/runner/settings_window.cpp +++ b/src/runner/settings_window.cpp @@ -805,6 +805,8 @@ std::string ESettingsWindowNames_to_string(ESettingsWindowNames value) return "CmdPal"; case ESettingsWindowNames::ZoomIt: return "ZoomIt"; + case ESettingsWindowNames::PowerDisplay: + return "PowerDisplay"; default: { Logger::error(L"Can't convert ESettingsWindowNames value={} to string", static_cast(value)); @@ -944,6 +946,10 @@ ESettingsWindowNames ESettingsWindowNames_from_string(std::string value) { return ESettingsWindowNames::ZoomIt; } + else if (value == "PowerDisplay") + { + return ESettingsWindowNames::PowerDisplay; + } else { Logger::error(L"Can't convert string value={} to ESettingsWindowNames", winrt::to_hstring(value)); diff --git a/src/runner/settings_window.h b/src/runner/settings_window.h index 507d1c65b4..4da4d70a7a 100644 --- a/src/runner/settings_window.h +++ b/src/runner/settings_window.h @@ -36,6 +36,7 @@ enum class ESettingsWindowNames NewPlus, CmdPal, ZoomIt, + PowerDisplay, }; std::string ESettingsWindowNames_to_string(ESettingsWindowNames value); diff --git a/src/settings-ui/Settings.UI.Controls/QuickAccess/QuickAccessViewModel.cs b/src/settings-ui/Settings.UI.Controls/QuickAccess/QuickAccessViewModel.cs index 50fc46af09..2fb626869d 100644 --- a/src/settings-ui/Settings.UI.Controls/QuickAccess/QuickAccessViewModel.cs +++ b/src/settings-ui/Settings.UI.Controls/QuickAccess/QuickAccessViewModel.cs @@ -65,6 +65,7 @@ namespace Microsoft.PowerToys.Settings.UI.Controls AddFlyoutMenuItem(ModuleType.FancyZones); AddFlyoutMenuItem(ModuleType.Hosts); AddFlyoutMenuItem(ModuleType.LightSwitch); + AddFlyoutMenuItem(ModuleType.PowerDisplay); AddFlyoutMenuItem(ModuleType.PowerLauncher); AddFlyoutMenuItem(ModuleType.PowerOCR); AddFlyoutMenuItem(ModuleType.RegistryPreview); @@ -121,6 +122,7 @@ namespace Microsoft.PowerToys.Settings.UI.Controls { ModuleType.ColorPicker => SettingsRepository.GetInstance(SettingsUtils.Default).SettingsConfig.Properties.ActivationShortcut.ToString(), ModuleType.FancyZones => SettingsRepository.GetInstance(SettingsUtils.Default).SettingsConfig.Properties.FancyzonesEditorHotkey.Value.ToString(), + ModuleType.PowerDisplay => SettingsRepository.GetInstance(SettingsUtils.Default).SettingsConfig.Properties.ActivationShortcut.ToString(), ModuleType.LightSwitch => SettingsRepository.GetInstance(SettingsUtils.Default).SettingsConfig.Properties.ToggleThemeHotkey.Value.ToString(), ModuleType.PowerLauncher => SettingsRepository.GetInstance(SettingsUtils.Default).SettingsConfig.Properties.OpenPowerLauncher.ToString(), ModuleType.PowerOCR => SettingsRepository.GetInstance(SettingsUtils.Default).SettingsConfig.Properties.ActivationShortcut.ToString(), diff --git a/src/settings-ui/Settings.UI.Library/EnabledModules.cs b/src/settings-ui/Settings.UI.Library/EnabledModules.cs index d7100d9ae4..f56176a1f0 100644 --- a/src/settings-ui/Settings.UI.Library/EnabledModules.cs +++ b/src/settings-ui/Settings.UI.Library/EnabledModules.cs @@ -546,6 +546,23 @@ namespace Microsoft.PowerToys.Settings.UI.Library } } + private bool powerDisplay; + + [JsonPropertyName("PowerDisplay")] + public bool PowerDisplay + { + get => powerDisplay; + set + { + if (powerDisplay != value) + { + LogTelemetryEvent(value); + powerDisplay = value; + NotifyChange(); + } + } + } + private void NotifyChange() { notifyEnabledChangedAction?.Invoke(); diff --git a/src/settings-ui/Settings.UI.Library/Helpers/ModuleHelper.cs b/src/settings-ui/Settings.UI.Library/Helpers/ModuleHelper.cs index f96eac8ce8..9b4581957c 100644 --- a/src/settings-ui/Settings.UI.Library/Helpers/ModuleHelper.cs +++ b/src/settings-ui/Settings.UI.Library/Helpers/ModuleHelper.cs @@ -73,6 +73,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library.Helpers ModuleType.MeasureTool => generalSettingsConfig.Enabled.MeasureTool, ModuleType.ShortcutGuide => generalSettingsConfig.Enabled.ShortcutGuide, ModuleType.PowerOCR => generalSettingsConfig.Enabled.PowerOcr, + ModuleType.PowerDisplay => generalSettingsConfig.Enabled.PowerDisplay, ModuleType.Workspaces => generalSettingsConfig.Enabled.Workspaces, ModuleType.ZoomIt => generalSettingsConfig.Enabled.ZoomIt, ModuleType.GeneralSettings => generalSettingsConfig.EnableQuickAccess, @@ -112,6 +113,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library.Helpers case ModuleType.MeasureTool: generalSettingsConfig.Enabled.MeasureTool = isEnabled; break; case ModuleType.ShortcutGuide: generalSettingsConfig.Enabled.ShortcutGuide = isEnabled; break; case ModuleType.PowerOCR: generalSettingsConfig.Enabled.PowerOcr = isEnabled; break; + case ModuleType.PowerDisplay: generalSettingsConfig.Enabled.PowerDisplay = isEnabled; break; case ModuleType.Workspaces: generalSettingsConfig.Enabled.Workspaces = isEnabled; break; case ModuleType.ZoomIt: generalSettingsConfig.Enabled.ZoomIt = isEnabled; break; case ModuleType.GeneralSettings: generalSettingsConfig.EnableQuickAccess = isEnabled; break; diff --git a/src/settings-ui/Settings.UI.Library/LightSwitchProperties.cs b/src/settings-ui/Settings.UI.Library/LightSwitchProperties.cs index 8f5bf88a19..4c56051ce9 100644 --- a/src/settings-ui/Settings.UI.Library/LightSwitchProperties.cs +++ b/src/settings-ui/Settings.UI.Library/LightSwitchProperties.cs @@ -17,6 +17,10 @@ namespace Microsoft.PowerToys.Settings.UI.Library public const string DefaultLatitude = "0.0"; public const string DefaultLongitude = "0.0"; public const string DefaultScheduleMode = "Off"; + public const bool DefaultEnableDarkModeProfile = false; + public const bool DefaultEnableLightModeProfile = false; + public const string DefaultDarkModeProfile = ""; + public const string DefaultLightModeProfile = ""; public static readonly HotkeySettings DefaultToggleThemeHotkey = new HotkeySettings(true, true, false, true, 0x44); // Ctrl+Win+Shift+D public LightSwitchProperties() @@ -31,6 +35,10 @@ namespace Microsoft.PowerToys.Settings.UI.Library SunsetOffset = new IntProperty(DefaultSunsetOffset); ScheduleMode = new StringProperty(DefaultScheduleMode); ToggleThemeHotkey = new KeyboardKeysProperty(DefaultToggleThemeHotkey); + EnableDarkModeProfile = new BoolProperty(DefaultEnableDarkModeProfile); + EnableLightModeProfile = new BoolProperty(DefaultEnableLightModeProfile); + DarkModeProfile = new StringProperty(DefaultDarkModeProfile); + LightModeProfile = new StringProperty(DefaultLightModeProfile); } [JsonPropertyName("changeSystem")] @@ -62,5 +70,17 @@ namespace Microsoft.PowerToys.Settings.UI.Library [JsonPropertyName("toggle-theme-hotkey")] public KeyboardKeysProperty ToggleThemeHotkey { get; set; } + + [JsonPropertyName("enableDarkModeProfile")] + public BoolProperty EnableDarkModeProfile { get; set; } + + [JsonPropertyName("enableLightModeProfile")] + public BoolProperty EnableLightModeProfile { get; set; } + + [JsonPropertyName("darkModeProfile")] + public StringProperty DarkModeProfile { get; set; } + + [JsonPropertyName("lightModeProfile")] + public StringProperty LightModeProfile { get; set; } } } diff --git a/src/settings-ui/Settings.UI.Library/LightSwitchSettings.cs b/src/settings-ui/Settings.UI.Library/LightSwitchSettings.cs index ce76ec72bf..4aa5647102 100644 --- a/src/settings-ui/Settings.UI.Library/LightSwitchSettings.cs +++ b/src/settings-ui/Settings.UI.Library/LightSwitchSettings.cs @@ -60,6 +60,10 @@ namespace Microsoft.PowerToys.Settings.UI.Library Latitude = new StringProperty(Properties.Latitude.Value), Longitude = new StringProperty(Properties.Longitude.Value), ToggleThemeHotkey = new KeyboardKeysProperty(Properties.ToggleThemeHotkey.Value), + EnableDarkModeProfile = new BoolProperty(Properties.EnableDarkModeProfile.Value), + EnableLightModeProfile = new BoolProperty(Properties.EnableLightModeProfile.Value), + DarkModeProfile = new StringProperty(Properties.DarkModeProfile.Value), + LightModeProfile = new StringProperty(Properties.LightModeProfile.Value), }, }; } diff --git a/src/settings-ui/Settings.UI.Library/MonitorInfo.cs b/src/settings-ui/Settings.UI.Library/MonitorInfo.cs new file mode 100644 index 0000000000..f53f682d3d --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/MonitorInfo.cs @@ -0,0 +1,694 @@ +// 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.Linq; +using System.Text.Json.Serialization; +using Microsoft.PowerToys.Settings.UI.Library.Helpers; +using PowerDisplay.Common.Drivers; +using PowerDisplay.Common.Models; +using PowerDisplay.Common.Utils; + +namespace Microsoft.PowerToys.Settings.UI.Library +{ + public class MonitorInfo : Observable + { + private string _name = string.Empty; + private string _id = string.Empty; + private string _communicationMethod = string.Empty; + private int _currentBrightness; + private int _colorTemperatureVcp = 0x05; // Default to 6500K preset (VCP 0x14 value) + private int _contrast; + private int _volume; + private bool _isHidden; + private bool _enableContrast; + private bool _enableVolume; + private bool _enableInputSource; + private bool _enableRotation; + private bool _enableColorTemperature; + private bool _enablePowerState; + private string _capabilitiesRaw = string.Empty; + private List _vcpCodesFormatted = new List(); + private int _monitorNumber; + private int _totalMonitorCount; + + // Feature support status (determined from capabilities) + private bool _supportsBrightness = true; // Brightness always shown even if unsupported + private bool _supportsContrast; + private bool _supportsColorTemperature; + private bool _supportsVolume; + private bool _supportsInputSource; + private bool _supportsPowerState; + + // Cached color temperature presets (computed from VcpCodesFormatted) + private ObservableCollection _availableColorPresetsCache; + private ObservableCollection _colorPresetsForDisplayCache; + private int _lastColorTemperatureVcpForCache = -1; + + /// + /// Invalidates the color preset cache and notifies property changes. + /// Call this when VcpCodesFormatted or SupportsColorTemperature changes. + /// + private void InvalidateColorPresetCache() + { + _availableColorPresetsCache = null; + _colorPresetsForDisplayCache = null; + _lastColorTemperatureVcpForCache = -1; + OnPropertyChanged(nameof(ColorPresetsForDisplay)); + } + + public MonitorInfo() + { + } + + [JsonPropertyName("name")] + public string Name + { + get => _name; + set + { + if (_name != value) + { + _name = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(DisplayName)); + } + } + } + + /// + /// Gets or sets the monitor number (Windows DISPLAY number, e.g., 1, 2, 3...). + /// + [JsonPropertyName("monitorNumber")] + public int MonitorNumber + { + get => _monitorNumber; + set + { + if (_monitorNumber != value) + { + _monitorNumber = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(DisplayName)); + } + } + } + + /// + /// Gets or sets the total number of monitors (used for dynamic display name). + /// This is not serialized; it's set by the ViewModel. + /// + [JsonIgnore] + public int TotalMonitorCount + { + get => _totalMonitorCount; + set + { + if (_totalMonitorCount != value) + { + _totalMonitorCount = value; + OnPropertyChanged(nameof(DisplayName)); + } + } + } + + /// + /// Gets the display name - includes monitor number when multiple monitors exist. + /// Follows the same logic as PowerDisplay UI's MonitorViewModel.DisplayName. + /// + [JsonIgnore] + public string DisplayName + { + get + { + // Show monitor number only when there are multiple monitors and MonitorNumber is valid + if (TotalMonitorCount > 1 && MonitorNumber > 0) + { + return $"{Name} {MonitorNumber}"; + } + + return Name; + } + } + + public string MonitorIconGlyph => CommunicationMethod.Contains("WMI", StringComparison.OrdinalIgnoreCase) + ? "\uE7F8" // Laptop icon for WMI + : "\uE7F4"; // External monitor icon for DDC/CI and others + + [JsonPropertyName("id")] + public string Id + { + get => _id; + set + { + if (_id != value) + { + _id = value; + OnPropertyChanged(); + } + } + } + + [JsonPropertyName("communicationMethod")] + public string CommunicationMethod + { + get => _communicationMethod; + set + { + if (_communicationMethod != value) + { + _communicationMethod = value; + OnPropertyChanged(); + } + } + } + + [JsonPropertyName("currentBrightness")] + public int CurrentBrightness + { + get => _currentBrightness; + set + { + if (_currentBrightness != value) + { + _currentBrightness = value; + OnPropertyChanged(); + } + } + } + + /// + /// Gets or sets the color temperature VCP preset value (raw DDC/CI value from VCP code 0x14). + /// This stores the raw VCP value (e.g., 0x05 for 6500K preset), not the Kelvin temperature. + /// + [JsonPropertyName("colorTemperatureVcp")] + public int ColorTemperatureVcp + { + get => _colorTemperatureVcp; + set + { + if (_colorTemperatureVcp != value) + { + _colorTemperatureVcp = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(ColorPresetsForDisplay)); // Update display list when current value changes + } + } + } + + /// + /// Gets or sets the current contrast value (0-100). + /// + [JsonPropertyName("contrast")] + public int Contrast + { + get => _contrast; + set + { + if (_contrast != value) + { + _contrast = value; + OnPropertyChanged(); + } + } + } + + /// + /// Gets or sets the current volume value (0-100). + /// + [JsonPropertyName("volume")] + public int Volume + { + get => _volume; + set + { + if (_volume != value) + { + _volume = value; + OnPropertyChanged(); + } + } + } + + [JsonPropertyName("isHidden")] + public bool IsHidden + { + get => _isHidden; + set + { + if (_isHidden != value) + { + _isHidden = value; + OnPropertyChanged(); + } + } + } + + [JsonPropertyName("enableContrast")] + public bool EnableContrast + { + get => _enableContrast; + set + { + if (_enableContrast != value) + { + _enableContrast = value; + OnPropertyChanged(); + } + } + } + + [JsonPropertyName("enableVolume")] + public bool EnableVolume + { + get => _enableVolume; + set + { + if (_enableVolume != value) + { + _enableVolume = value; + OnPropertyChanged(); + } + } + } + + [JsonPropertyName("enableInputSource")] + public bool EnableInputSource + { + get => _enableInputSource; + set + { + if (_enableInputSource != value) + { + _enableInputSource = value; + OnPropertyChanged(); + } + } + } + + [JsonPropertyName("enableRotation")] + public bool EnableRotation + { + get => _enableRotation; + set + { + if (_enableRotation != value) + { + _enableRotation = value; + OnPropertyChanged(); + } + } + } + + [JsonPropertyName("enableColorTemperature")] + public bool EnableColorTemperature + { + get => _enableColorTemperature; + set + { + if (_enableColorTemperature != value) + { + _enableColorTemperature = value; + OnPropertyChanged(); + } + } + } + + [JsonPropertyName("enablePowerState")] + public bool EnablePowerState + { + get => _enablePowerState; + set + { + if (_enablePowerState != value) + { + _enablePowerState = value; + OnPropertyChanged(); + } + } + } + + [JsonPropertyName("capabilitiesRaw")] + public string CapabilitiesRaw + { + get => _capabilitiesRaw; + set + { + if (_capabilitiesRaw != value) + { + _capabilitiesRaw = value ?? string.Empty; + OnPropertyChanged(); + OnPropertyChanged(nameof(HasCapabilities)); + } + } + } + + [JsonPropertyName("vcpCodesFormatted")] + public List VcpCodesFormatted + { + get => _vcpCodesFormatted; + set + { + var newValue = value ?? new List(); + + // Only update if content actually changed (compare by VCP code list content) + if (AreVcpCodesEqual(_vcpCodesFormatted, newValue)) + { + return; + } + + _vcpCodesFormatted = newValue; + OnPropertyChanged(); + InvalidateColorPresetCache(); + } + } + + /// + /// Compare two VcpCodesFormatted lists for equality by content. + /// Returns true if both lists have the same VCP codes (by code value). + /// + private static bool AreVcpCodesEqual(List list1, List list2) + { + if (list1 == null && list2 == null) + { + return true; + } + + if (list1 == null || list2 == null) + { + return false; + } + + if (list1.Count != list2.Count) + { + return false; + } + + // Compare by code values - order matters for our use case + for (int i = 0; i < list1.Count; i++) + { + if (list1[i].Code != list2[i].Code) + { + return false; + } + + // Also compare ValueList count to detect preset changes + var values1 = list1[i].ValueList; + var values2 = list2[i].ValueList; + if ((values1?.Count ?? 0) != (values2?.Count ?? 0)) + { + return false; + } + } + + return true; + } + + [JsonPropertyName("supportsBrightness")] + public bool SupportsBrightness + { + get => _supportsBrightness; + set + { + if (_supportsBrightness != value) + { + _supportsBrightness = value; + OnPropertyChanged(); + } + } + } + + [JsonPropertyName("supportsContrast")] + public bool SupportsContrast + { + get => _supportsContrast; + set + { + if (_supportsContrast != value) + { + _supportsContrast = value; + OnPropertyChanged(); + } + } + } + + [JsonPropertyName("supportsColorTemperature")] + public bool SupportsColorTemperature + { + get => _supportsColorTemperature; + set + { + if (_supportsColorTemperature != value) + { + _supportsColorTemperature = value; + OnPropertyChanged(); + InvalidateColorPresetCache(); // Notifies ColorPresetsForDisplay + } + } + } + + [JsonPropertyName("supportsVolume")] + public bool SupportsVolume + { + get => _supportsVolume; + set + { + if (_supportsVolume != value) + { + _supportsVolume = value; + OnPropertyChanged(); + } + } + } + + [JsonPropertyName("supportsInputSource")] + public bool SupportsInputSource + { + get => _supportsInputSource; + set + { + if (_supportsInputSource != value) + { + _supportsInputSource = value; + OnPropertyChanged(); + } + } + } + + [JsonPropertyName("supportsPowerState")] + public bool SupportsPowerState + { + get => _supportsPowerState; + set + { + if (_supportsPowerState != value) + { + _supportsPowerState = value; + OnPropertyChanged(); + } + } + } + + /// + /// Gets available color temperature presets computed from VcpCodesFormatted (VCP code 0x14). + /// This is a computed property that parses the VCP capabilities data on-demand. + /// + private ObservableCollection AvailableColorPresets + { + get + { + // Return cached value if available + if (_availableColorPresetsCache != null) + { + return _availableColorPresetsCache; + } + + // Compute from VcpCodesFormatted + _availableColorPresetsCache = ComputeAvailableColorPresets(); + return _availableColorPresetsCache; + } + } + + /// + /// Compute available color presets from VcpCodesFormatted (VCP code 0x14). + /// Uses ColorTemperatureHelper from PowerDisplay.Lib for shared computation logic. + /// + private ObservableCollection ComputeAvailableColorPresets() + { + // Check if color temperature is supported + if (!_supportsColorTemperature || _vcpCodesFormatted == null) + { + return new ObservableCollection(); + } + + // Find VCP code 0x14 (Color Temperature / Select Color Preset) + var colorTempVcp = _vcpCodesFormatted.FirstOrDefault(v => + !string.IsNullOrEmpty(v.Code) && + int.TryParse( + v.Code.StartsWith("0x", StringComparison.OrdinalIgnoreCase) ? v.Code[2..] : v.Code, + System.Globalization.NumberStyles.HexNumber, + System.Globalization.CultureInfo.InvariantCulture, + out int code) && + code == NativeConstants.VcpCodeSelectColorPreset); + + // No VCP 0x14 or no values + if (colorTempVcp == null || colorTempVcp.ValueList == null || colorTempVcp.ValueList.Count == 0) + { + return new ObservableCollection(); + } + + // Extract VCP values as tuples for ColorTemperatureHelper + var colorTempValues = colorTempVcp.ValueList + .Select(valueInfo => + { + var hex = valueInfo.Value; + if (string.IsNullOrEmpty(hex)) + { + return (VcpValue: 0, Name: valueInfo.Name); + } + + var cleanHex = hex.StartsWith("0x", StringComparison.OrdinalIgnoreCase) ? hex[2..] : hex; + bool parsed = int.TryParse(cleanHex, System.Globalization.NumberStyles.HexNumber, System.Globalization.CultureInfo.InvariantCulture, out int vcpValue); + return (VcpValue: parsed ? vcpValue : 0, Name: valueInfo.Name); + }) + .Where(x => x.VcpValue > 0); + + // Use shared helper to compute presets, then convert to nested type for XAML compatibility + var basePresets = ColorTemperatureHelper.ComputeColorPresets(colorTempValues); + var presetList = basePresets.Select(p => new ColorPresetItem(p.VcpValue, p.DisplayName)); + return new ObservableCollection(presetList); + } + + /// + /// Gets color presets for display in ComboBox, includes current value if not in preset list. + /// Uses caching to avoid recreating collections on every access. + /// + [JsonIgnore] + public ObservableCollection ColorPresetsForDisplay + { + get + { + // Return cached value if available and color temperature hasn't changed + if (_colorPresetsForDisplayCache != null && _lastColorTemperatureVcpForCache == _colorTemperatureVcp) + { + return _colorPresetsForDisplayCache; + } + + var presets = AvailableColorPresets; + if (presets == null || presets.Count == 0) + { + _colorPresetsForDisplayCache = new ObservableCollection(); + _lastColorTemperatureVcpForCache = _colorTemperatureVcp; + return _colorPresetsForDisplayCache; + } + + // Check if current value is in the preset list + var currentValueInList = presets.Any(p => p.VcpValue == _colorTemperatureVcp); + + if (currentValueInList) + { + // Current value is in the list, return as-is + _colorPresetsForDisplayCache = presets; + } + else + { + // Current value is not in the preset list - add it at the beginning + var displayList = new List(); + + // Add current value with "Custom" indicator using shared helper + var displayName = ColorTemperatureHelper.FormatCustomColorTemperatureDisplayName(_colorTemperatureVcp); + displayList.Add(new ColorPresetItem(_colorTemperatureVcp, displayName)); + + // Add all supported presets + displayList.AddRange(presets); + + _colorPresetsForDisplayCache = new ObservableCollection(displayList); + } + + _lastColorTemperatureVcpForCache = _colorTemperatureVcp; + return _colorPresetsForDisplayCache; + } + } + + [JsonIgnore] + public bool HasCapabilities => !string.IsNullOrEmpty(_capabilitiesRaw); + + [JsonIgnore] + public bool ShowCapabilitiesWarning => _communicationMethod.Contains("WMI", StringComparison.OrdinalIgnoreCase); + + /// + /// Generate formatted text of all VCP codes for clipboard + /// + public string GetVcpCodesAsText() + { + if (_vcpCodesFormatted == null || _vcpCodesFormatted.Count == 0) + { + return "No VCP codes detected"; + } + + var lines = new List(); + lines.Add($"VCP Capabilities for: {_name}"); + lines.Add($"Monitor ID: {_id}"); + lines.Add(string.Empty); + lines.Add("Detected VCP Codes:"); + lines.Add(new string('-', 50)); + + foreach (var vcp in _vcpCodesFormatted) + { + lines.Add(string.Empty); + lines.Add(vcp.Title); + if (vcp.HasValues) + { + lines.Add($" {vcp.Values}"); + } + } + + lines.Add(string.Empty); + lines.Add(new string('-', 50)); + lines.Add($"Total: {_vcpCodesFormatted.Count} VCP codes"); + + return string.Join(System.Environment.NewLine, lines); + } + + /// + /// Update this monitor's properties from another MonitorInfo instance. + /// This preserves the object reference while updating all properties. + /// + /// The source MonitorInfo to copy properties from + public void UpdateFrom(MonitorInfo other) + { + if (other == null) + { + return; + } + + // Update all properties that can change + Name = other.Name; + Id = other.Id; + CommunicationMethod = other.CommunicationMethod; + CurrentBrightness = other.CurrentBrightness; + Contrast = other.Contrast; + Volume = other.Volume; + ColorTemperatureVcp = other.ColorTemperatureVcp; + IsHidden = other.IsHidden; + EnableContrast = other.EnableContrast; + EnableVolume = other.EnableVolume; + EnableInputSource = other.EnableInputSource; + EnableRotation = other.EnableRotation; + EnableColorTemperature = other.EnableColorTemperature; + EnablePowerState = other.EnablePowerState; + CapabilitiesRaw = other.CapabilitiesRaw; + VcpCodesFormatted = other.VcpCodesFormatted; + SupportsBrightness = other.SupportsBrightness; + SupportsContrast = other.SupportsContrast; + SupportsColorTemperature = other.SupportsColorTemperature; + SupportsVolume = other.SupportsVolume; + SupportsInputSource = other.SupportsInputSource; + SupportsPowerState = other.SupportsPowerState; + MonitorNumber = other.MonitorNumber; + } + } +} diff --git a/src/settings-ui/Settings.UI.Library/PowerDisplayActionMessage.cs b/src/settings-ui/Settings.UI.Library/PowerDisplayActionMessage.cs new file mode 100644 index 0000000000..06ca2bf68f --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/PowerDisplayActionMessage.cs @@ -0,0 +1,32 @@ +// 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.Text.Json.Serialization; + +namespace Microsoft.PowerToys.Settings.UI.Library +{ + /// + /// Message for PowerDisplay module actions + /// + public class PowerDisplayActionMessage + { + [JsonPropertyName("action")] + public ActionData Action { get; set; } + + public class ActionData + { + [JsonPropertyName("PowerDisplay")] + public PowerDisplayAction PowerDisplay { get; set; } + } + + public class PowerDisplayAction + { + [JsonPropertyName("action_name")] + public string ActionName { get; set; } + + [JsonPropertyName("value")] + public string Value { get; set; } + } + } +} diff --git a/src/settings-ui/Settings.UI.Library/PowerDisplayProperties.cs b/src/settings-ui/Settings.UI.Library/PowerDisplayProperties.cs new file mode 100644 index 0000000000..25a4354474 --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/PowerDisplayProperties.cs @@ -0,0 +1,65 @@ +// 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.Collections.Generic; +using System.Text.Json.Serialization; +using PowerDisplay.Common.Models; +using Settings.UI.Library.Attributes; + +namespace Microsoft.PowerToys.Settings.UI.Library +{ + public class PowerDisplayProperties + { + [CmdConfigureIgnore] + public HotkeySettings DefaultActivationShortcut => new HotkeySettings(true, true, false, true, 0x44); // Ctrl+Shift+Win+D (win, ctrl, alt, shift, code) + + public PowerDisplayProperties() + { + ActivationShortcut = DefaultActivationShortcut; + MonitorRefreshDelay = 5; + Monitors = new List(); + RestoreSettingsOnStartup = false; + ShowSystemTrayIcon = true; + ShowProfileSwitcher = true; + ShowIdentifyMonitorsButton = true; + + // Note: saved_monitor_settings has been moved to monitor_state.json + // which is managed separately by PowerDisplay app + } + + [JsonPropertyName("activation_shortcut")] + public HotkeySettings ActivationShortcut { get; set; } + + /// + /// Gets or sets delay in seconds before refreshing monitors after display changes (hot-plug). + /// This allows hardware to stabilize before querying DDC/CI. + /// + [JsonPropertyName("monitor_refresh_delay")] + public int MonitorRefreshDelay { get; set; } + + [JsonPropertyName("monitors")] + public List Monitors { get; set; } + + [JsonPropertyName("restore_settings_on_startup")] + public bool RestoreSettingsOnStartup { get; set; } + + [JsonPropertyName("show_system_tray_icon")] + public bool ShowSystemTrayIcon { get; set; } + + /// + /// Gets or sets whether to show the profile switcher button in the flyout UI. + /// Default is true. When false, the profile switcher is hidden (but profiles still work via Settings). + /// Note: Also hidden when no profiles exist. + /// + [JsonPropertyName("show_profile_switcher")] + public bool ShowProfileSwitcher { get; set; } + + /// + /// Gets or sets whether to show the identify monitors button in the flyout UI. + /// Default is true. + /// + [JsonPropertyName("show_identify_monitors_button")] + public bool ShowIdentifyMonitorsButton { get; set; } + } +} diff --git a/src/settings-ui/Settings.UI.Library/PowerDisplaySettings.cs b/src/settings-ui/Settings.UI.Library/PowerDisplaySettings.cs new file mode 100644 index 0000000000..f9fc8df5fd --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/PowerDisplaySettings.cs @@ -0,0 +1,50 @@ +// 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.Collections.Generic; +using System.Text.Json.Serialization; + +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library.Helpers; +using Microsoft.PowerToys.Settings.UI.Library.Interfaces; + +namespace Microsoft.PowerToys.Settings.UI.Library +{ + public class PowerDisplaySettings : BasePTModuleSettings, ISettingsConfig, IHotkeyConfig + { + public const string ModuleName = "PowerDisplay"; + + [JsonPropertyName("properties")] + public PowerDisplayProperties Properties { get; set; } + + public PowerDisplaySettings() + { + Properties = new PowerDisplayProperties(); + Version = "1"; + Name = ModuleName; + } + + public string GetModuleName() + => Name; + + // This can be utilized in the future if the settings.json file is to be modified/deleted. + public bool UpgradeSettingsConfiguration() + => false; + + public ModuleType GetModuleType() => ModuleType.PowerDisplay; + + public HotkeyAccessor[] GetAllHotkeyAccessors() + { + var hotkeyAccessors = new List + { + new HotkeyAccessor( + () => Properties.ActivationShortcut, + value => Properties.ActivationShortcut = value ?? Properties.DefaultActivationShortcut, + "Activation_Shortcut"), + }; + + return hotkeyAccessors.ToArray(); + } + } +} diff --git a/src/settings-ui/Settings.UI.Library/Settings.UI.Library.csproj b/src/settings-ui/Settings.UI.Library/Settings.UI.Library.csproj index c3832b11c5..8ecd0da804 100644 --- a/src/settings-ui/Settings.UI.Library/Settings.UI.Library.csproj +++ b/src/settings-ui/Settings.UI.Library/Settings.UI.Library.csproj @@ -23,6 +23,7 @@ + diff --git a/src/settings-ui/Settings.UI.Library/SettingsSerializationContext.cs b/src/settings-ui/Settings.UI.Library/SettingsSerializationContext.cs index 2fab97c538..366f98cd8b 100644 --- a/src/settings-ui/Settings.UI.Library/SettingsSerializationContext.cs +++ b/src/settings-ui/Settings.UI.Library/SettingsSerializationContext.cs @@ -2,7 +2,9 @@ // 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.Collections.Generic; using System.Text.Json.Serialization; +using PowerDisplay.Common.Models; using SettingsUILibrary = Settings.UI.Library; using SettingsUILibraryHelpers = Settings.UI.Library.Helpers; @@ -65,6 +67,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library [JsonSerializable(typeof(NewPlusSettings))] [JsonSerializable(typeof(PeekSettings))] [JsonSerializable(typeof(PowerAccentSettings))] + [JsonSerializable(typeof(PowerDisplaySettings))] [JsonSerializable(typeof(PowerLauncherSettings))] [JsonSerializable(typeof(PowerOcrSettings))] [JsonSerializable(typeof(PowerPreviewSettings))] @@ -102,6 +105,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library [JsonSerializable(typeof(PeekProperties))] [JsonSerializable(typeof(SettingsUILibrary.PeekPreviewSettings))] [JsonSerializable(typeof(PowerAccentProperties))] + [JsonSerializable(typeof(PowerDisplayProperties))] [JsonSerializable(typeof(PowerLauncherProperties))] [JsonSerializable(typeof(PowerOcrProperties))] [JsonSerializable(typeof(PowerPreviewProperties))] @@ -134,13 +138,33 @@ namespace Microsoft.PowerToys.Settings.UI.Library [JsonSerializable(typeof(AdvancedPasteAdditionalAction))] [JsonSerializable(typeof(AdvancedPastePasteAsFileAction))] [JsonSerializable(typeof(AdvancedPasteTranscodeAction))] - [JsonSerializable(typeof(PasteAIConfiguration))] - [JsonSerializable(typeof(PasteAIProviderDefinition))] [JsonSerializable(typeof(ImageResizerSizes))] [JsonSerializable(typeof(ImageResizerCustomSizeProperty))] [JsonSerializable(typeof(KeyboardKeysProperty))] + [JsonSerializable(typeof(MonitorInfo))] + [JsonSerializable(typeof(PowerDisplayActionMessage))] + [JsonSerializable(typeof(PowerDisplayActionMessage.ActionData))] + [JsonSerializable(typeof(PowerDisplayActionMessage.PowerDisplayAction))] + [JsonSerializable(typeof(VcpCodeDisplayInfo))] + [JsonSerializable(typeof(VcpValueInfo))] + [JsonSerializable(typeof(List))] + [JsonSerializable(typeof(List))] + [JsonSerializable(typeof(List))] + [JsonSerializable(typeof(List))] [JsonSerializable(typeof(SettingsUILibraryHelpers.SearchLocation))] + // AdvancedPaste AI Provider Types (for AOT compatibility) + [JsonSerializable(typeof(PasteAIConfiguration))] + [JsonSerializable(typeof(PasteAIProviderDefinition))] + [JsonSerializable(typeof(System.Collections.ObjectModel.ObservableCollection))] + + // PowerDisplay Profile Types (for AOT compatibility) + [JsonSerializable(typeof(PowerDisplayProfile))] + [JsonSerializable(typeof(List))] + [JsonSerializable(typeof(PowerDisplayProfiles))] + [JsonSerializable(typeof(ProfileMonitorSetting))] + [JsonSerializable(typeof(List))] + // IPC Send Message Wrapper Classes (Snd*) [JsonSerializable(typeof(SndAwakeSettings))] [JsonSerializable(typeof(SndCursorWrapSettings))] diff --git a/src/settings-ui/Settings.UI.Library/VcpCodeDisplayInfo.cs b/src/settings-ui/Settings.UI.Library/VcpCodeDisplayInfo.cs new file mode 100644 index 0000000000..5edaaa72b2 --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/VcpCodeDisplayInfo.cs @@ -0,0 +1,29 @@ +// 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.Text.Json.Serialization; + +namespace Microsoft.PowerToys.Settings.UI.Library +{ + /// + /// Formatted VCP code display information + /// + public class VcpCodeDisplayInfo + { + [JsonPropertyName("code")] + public string Code { get; set; } = string.Empty; + + [JsonPropertyName("title")] + public string Title { get; set; } = string.Empty; + + [JsonPropertyName("values")] + public string Values { get; set; } = string.Empty; + + [JsonPropertyName("hasValues")] + public bool HasValues { get; set; } + + [JsonPropertyName("valueList")] + public System.Collections.Generic.List ValueList { get; set; } = new System.Collections.Generic.List(); + } +} diff --git a/src/settings-ui/Settings.UI.Library/VcpValueInfo.cs b/src/settings-ui/Settings.UI.Library/VcpValueInfo.cs new file mode 100644 index 0000000000..53dff29f33 --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/VcpValueInfo.cs @@ -0,0 +1,20 @@ +// 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.Text.Json.Serialization; + +namespace Microsoft.PowerToys.Settings.UI.Library +{ + /// + /// Individual VCP value information + /// + public class VcpValueInfo + { + [JsonPropertyName("value")] + public string Value { get; set; } = string.Empty; + + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + } +} diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/PowerDisplay.png b/src/settings-ui/Settings.UI/Assets/Settings/Icons/PowerDisplay.png new file mode 100644 index 0000000000..0991f565ee Binary files /dev/null and b/src/settings-ui/Settings.UI/Assets/Settings/Icons/PowerDisplay.png differ diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Modules/OOBE/PowerDisplay.gif b/src/settings-ui/Settings.UI/Assets/Settings/Modules/OOBE/PowerDisplay.gif new file mode 100644 index 0000000000..8da2aaa14e Binary files /dev/null and b/src/settings-ui/Settings.UI/Assets/Settings/Modules/OOBE/PowerDisplay.gif differ diff --git a/src/settings-ui/Settings.UI/Helpers/ModuleGpoHelper.cs b/src/settings-ui/Settings.UI/Helpers/ModuleGpoHelper.cs index 9517c91b21..18a17937dc 100644 --- a/src/settings-ui/Settings.UI/Helpers/ModuleGpoHelper.cs +++ b/src/settings-ui/Settings.UI/Helpers/ModuleGpoHelper.cs @@ -44,6 +44,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 GPOWrapper.GetConfiguredPowerDisplayEnabledValue(); case ModuleType.ZoomIt: return GPOWrapper.GetConfiguredZoomItEnabledValue(); default: return GpoRuleConfigured.Unavailable; } @@ -83,6 +84,7 @@ namespace Microsoft.PowerToys.Settings.UI.Helpers ModuleType.MeasureTool => typeof(MeasureToolPage), ModuleType.ShortcutGuide => typeof(ShortcutGuidePage), ModuleType.PowerOCR => typeof(PowerOcrPage), + ModuleType.PowerDisplay => typeof(PowerDisplayPage), ModuleType.ZoomIt => typeof(ZoomItPage), _ => typeof(DashboardPage), // never called, all values listed above }; diff --git a/src/settings-ui/Settings.UI/OOBE/Enums/PowerToysModules.cs b/src/settings-ui/Settings.UI/OOBE/Enums/PowerToysModules.cs index d14c95fcad..93c1cc3e07 100644 --- a/src/settings-ui/Settings.UI/OOBE/Enums/PowerToysModules.cs +++ b/src/settings-ui/Settings.UI/OOBE/Enums/PowerToysModules.cs @@ -33,6 +33,7 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Enums Hosts, Workspaces, RegistryPreview, + PowerDisplay, NewPlus, ZoomIt, } diff --git a/src/settings-ui/Settings.UI/PowerToys.Settings.csproj b/src/settings-ui/Settings.UI/PowerToys.Settings.csproj index 261ca48155..6274dc672a 100644 --- a/src/settings-ui/Settings.UI/PowerToys.Settings.csproj +++ b/src/settings-ui/Settings.UI/PowerToys.Settings.csproj @@ -123,6 +123,7 @@ + diff --git a/src/settings-ui/Settings.UI/SerializationContext/SourceGenerationContextContext.cs b/src/settings-ui/Settings.UI/SerializationContext/SourceGenerationContextContext.cs index d8c53aac76..36fd08ecd2 100644 --- a/src/settings-ui/Settings.UI/SerializationContext/SourceGenerationContextContext.cs +++ b/src/settings-ui/Settings.UI/SerializationContext/SourceGenerationContextContext.cs @@ -19,6 +19,7 @@ namespace Microsoft.PowerToys.Settings.UI.SerializationContext; [JsonSerializable(typeof(AlwaysOnTopSettings))] [JsonSerializable(typeof(ColorPickerSettings))] [JsonSerializable(typeof(CropAndLockSettings))] +[JsonSerializable(typeof(CursorWrapSettings))] [JsonSerializable(typeof(Dictionary>))] [JsonSerializable(typeof(FileLocksmithSettings))] [JsonSerializable(typeof(FindMyMouseSettings))] @@ -34,11 +35,16 @@ namespace Microsoft.PowerToys.Settings.UI.SerializationContext; [JsonSerializable(typeof(PowerLauncherSettings))] [JsonSerializable(typeof(PowerOcrSettings))] [JsonSerializable(typeof(PowerOcrSettings))] +[JsonSerializable(typeof(PowerDisplaySettings))] [JsonSerializable(typeof(RegistryPreviewSettings))] [JsonSerializable(typeof(ShortcutConflictProperties))] [JsonSerializable(typeof(ShortcutGuideSettings))] [JsonSerializable(typeof(WINDOWPLACEMENT))] [JsonSerializable(typeof(WorkspacesSettings))] +[JsonSerializable(typeof(ZoomItSettings))] +[JsonSerializable(typeof(PasteAIConfiguration))] +[JsonSerializable(typeof(PasteAIProviderDefinition))] +[JsonSerializable(typeof(System.Collections.ObjectModel.ObservableCollection))] public sealed partial class SourceGenerationContextContext : JsonSerializerContext { } diff --git a/src/settings-ui/Settings.UI/SettingsXAML/App.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/App.xaml.cs index 7f14f1809e..026b142e7c 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/App.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/App.xaml.cs @@ -415,6 +415,7 @@ namespace Microsoft.PowerToys.Settings.UI case "Workspaces": return typeof(WorkspacesPage); case "CmdPal": return typeof(CmdPalPage); case "ZoomIt": return typeof(ZoomItPage); + case "PowerDisplay": return typeof(PowerDisplayPage); default: // Fallback to Dashboard Debug.Assert(false, "Unexpected SettingsWindow argument value"); diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobePowerDisplay.xaml b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobePowerDisplay.xaml new file mode 100644 index 0000000000..740431a01d --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobePowerDisplay.xaml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + - - - + + + + + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/LightSwitchPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/LightSwitchPage.xaml.cs index bd6d01edde..b366fd7a2b 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/LightSwitchPage.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/LightSwitchPage.xaml.cs @@ -8,6 +8,7 @@ using System.IO; using System.IO.Abstractions; using System.Linq; using System.Threading.Tasks; +using Common.UI; using ManagedCommon; using Microsoft.PowerToys.Settings.UI.Helpers; using Microsoft.PowerToys.Settings.UI.Library; @@ -380,5 +381,10 @@ namespace Microsoft.PowerToys.Settings.UI.Views this.LocationWarningBar.Visibility = Visibility.Visible; } } + + private void NavigatePowerDisplaySettings_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) + { + ShellPage.Navigate(typeof(PowerDisplayPage)); + } } } diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerDisplayPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerDisplayPage.xaml new file mode 100644 index 0000000000..08d736a0fc --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerDisplayPage.xaml @@ -0,0 +1,263 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerDisplayPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerDisplayPage.xaml.cs new file mode 100644 index 0000000000..641311daad --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerDisplayPage.xaml.cs @@ -0,0 +1,218 @@ +// 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.Linq; +using System.Threading.Tasks; +using CommunityToolkit.WinUI.Controls; +using Microsoft.PowerToys.Settings.UI.Helpers; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.PowerToys.Settings.UI.ViewModels; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using PowerDisplay.Common.Models; +using PowerDisplay.Common.Utils; +using Windows.ApplicationModel.DataTransfer; + +namespace Microsoft.PowerToys.Settings.UI.Views +{ + public sealed partial class PowerDisplayPage : NavigablePage, IRefreshablePage + { + private PowerDisplayViewModel ViewModel { get; set; } + + public PowerDisplayPage() + { + var settingsUtils = SettingsUtils.Default; + ViewModel = new PowerDisplayViewModel( + settingsUtils, + SettingsRepository.GetInstance(settingsUtils), + SettingsRepository.GetInstance(settingsUtils), + ShellPage.SendDefaultIPCMessage); + DataContext = ViewModel; + InitializeComponent(); + Loaded += (s, e) => ViewModel.OnPageLoaded(); + } + + public void RefreshEnabledState() + { + ViewModel.RefreshEnabledState(); + } + + private void CopyVcpCodes_Click(object sender, RoutedEventArgs e) + { + if (sender is Button button && button.Tag is MonitorInfo monitor) + { + var vcpText = monitor.GetVcpCodesAsText(); + var dataPackage = new DataPackage(); + dataPackage.SetText(vcpText); + Clipboard.SetContent(dataPackage); + } + } + + // Profile button event handlers + private void ProfileButton_Click(object sender, RoutedEventArgs e) + { + if (sender is Button button && button.Tag is PowerDisplayProfile profile) + { + ViewModel.ApplyProfile(profile); + } + } + + private async void AddProfileButton_Click(object sender, RoutedEventArgs e) + { + if (ViewModel.Monitors == null || ViewModel.Monitors.Count == 0) + { + return; + } + + var defaultName = GenerateDefaultProfileName(); + var dialog = new ProfileEditorDialog(ViewModel.Monitors, defaultName); + dialog.XamlRoot = this.XamlRoot; + + var result = await dialog.ShowAsync(); + + if (result == ContentDialogResult.Primary && dialog.ResultProfile != null) + { + ViewModel.CreateProfile(dialog.ResultProfile); + } + } + + private async void EditProfile_Click(object sender, RoutedEventArgs e) + { + var menuItem = sender as MenuFlyoutItem; + if (menuItem?.Tag is PowerDisplayProfile profile) + { + var dialog = new ProfileEditorDialog(ViewModel.Monitors, profile.Name); + dialog.XamlRoot = this.XamlRoot; + + // Pre-fill with existing profile settings + dialog.PreFillProfile(profile); + + var result = await dialog.ShowAsync(); + + if (result == ContentDialogResult.Primary && dialog.ResultProfile != null) + { + ViewModel.UpdateProfile(profile.Name, dialog.ResultProfile); + } + } + } + + private async void DeleteProfile_Click(object sender, RoutedEventArgs e) + { + var menuItem = sender as MenuFlyoutItem; + if (menuItem?.Tag is PowerDisplayProfile profile) + { + var resourceLoader = ResourceLoaderInstance.ResourceLoader; + var dialog = new ContentDialog + { + XamlRoot = this.XamlRoot, + 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, + }; + + var result = await dialog.ShowAsync(); + + if (result == ContentDialogResult.Primary) + { + ViewModel.DeleteProfile(profile.Name); + } + } + } + + private string GenerateDefaultProfileName() + { + // Use shared ProfileHelper for consistent profile name generation + var existingNames = ViewModel.Profiles.Select(p => p.Name).ToHashSet(); + var resourceLoader = ResourceLoaderInstance.ResourceLoader; + var baseName = resourceLoader.GetString("PowerDisplay_Profile_DefaultBaseName"); + return ProfileHelper.GenerateUniqueProfileName(existingNames, baseName); + } + + // Flag to prevent reentrant handling during programmatic checkbox changes + private bool _isRestoringColorTempCheckbox; + + private async void EnableColorTemperature_Click(object sender, RoutedEventArgs e) + { + // Skip if we're programmatically restoring the checkbox state + if (_isRestoringColorTempCheckbox) + { + return; + } + + if (sender is not CheckBox checkBox || checkBox.Tag is not MonitorInfo monitor) + { + return; + } + + // Only show warning when enabling (checking the box) + if (checkBox.IsChecked != true) + { + return; + } + + // Show confirmation dialog with color temperature warning + var resourceLoader = ResourceLoaderInstance.ResourceLoader; + var dialog = new ContentDialog + { + XamlRoot = this.XamlRoot, + Title = resourceLoader.GetString("PowerDisplay_ColorTemperature_WarningTitle"), + Content = new StackPanel + { + Spacing = 12, + Children = + { + new TextBlock + { + 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 = resourceLoader.GetString("PowerDisplay_ColorTemperature_WarningDescription"), + TextWrapping = TextWrapping.Wrap, + }, + new TextBlock + { + Text = resourceLoader.GetString("PowerDisplay_ColorTemperature_WarningList"), + TextWrapping = TextWrapping.Wrap, + Margin = new Thickness(20, 0, 0, 0), + }, + new TextBlock + { + Text = resourceLoader.GetString("PowerDisplay_ColorTemperature_WarningConfirm"), + FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, + TextWrapping = TextWrapping.Wrap, + }, + }, + }, + PrimaryButtonText = resourceLoader.GetString("PowerDisplay_ColorTemperature_EnableButton"), + CloseButtonText = resourceLoader.GetString("PowerDisplay_Dialog_Cancel"), + DefaultButton = ContentDialogButton.Close, + }; + + var result = await dialog.ShowAsync(); + + if (result != ContentDialogResult.Primary) + { + // User cancelled: revert checkbox to unchecked + _isRestoringColorTempCheckbox = true; + try + { + checkBox.IsChecked = false; + monitor.EnableColorTemperature = false; + } + finally + { + _isRestoringColorTempCheckbox = false; + } + } + } + } +} diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/ProfileEditorDialog.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/ProfileEditorDialog.xaml new file mode 100644 index 0000000000..bd0f0269c8 --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/ProfileEditorDialog.xaml @@ -0,0 +1,132 @@ + + + + + 0 + 0 + 0,8,0,8 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/ProfileEditorDialog.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/ProfileEditorDialog.xaml.cs new file mode 100644 index 0000000000..00baece4dc --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/ProfileEditorDialog.xaml.cs @@ -0,0 +1,104 @@ +// 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.Linq; +using Microsoft.PowerToys.Settings.UI.Helpers; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.PowerToys.Settings.UI.ViewModels; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using PowerDisplay.Common.Models; + +namespace Microsoft.PowerToys.Settings.UI.Views +{ + /// + /// Dialog for creating/editing PowerDisplay profiles + /// + public sealed partial class ProfileEditorDialog : ContentDialog + { + public ProfileEditorViewModel ViewModel { get; private set; } + + public PowerDisplayProfile? ResultProfile { get; private set; } + + public ProfileEditorDialog(ObservableCollection availableMonitors, string defaultName = "") + { + this.InitializeComponent(); + ViewModel = new ProfileEditorViewModel(availableMonitors, defaultName); + + // Set localized strings for ContentDialog + var resourceLoader = ResourceLoaderInstance.ResourceLoader; + Title = resourceLoader.GetString("PowerDisplay_ProfileEditor_Title"); + PrimaryButtonText = resourceLoader.GetString("PowerDisplay_Dialog_Save"); + CloseButtonText = resourceLoader.GetString("PowerDisplay_Dialog_Cancel"); + } + + private void ContentDialog_PrimaryButtonClick(ContentDialog sender, ContentDialogButtonClickEventArgs args) + { + if (ViewModel.CanSave) + { + ResultProfile = ViewModel.CreateProfile(); + } + } + + private void ContentDialog_CloseButtonClick(ContentDialog sender, ContentDialogButtonClickEventArgs args) + { + ResultProfile = null; + } + + /// + /// Pre-fill the dialog with existing profile data + /// + public void PreFillProfile(PowerDisplayProfile profile) + { + if (profile == null || ViewModel == null) + { + return; + } + + // Set profile name + ViewModel.ProfileName = profile.Name; + + // Pre-fill monitor settings from existing profile + foreach (var monitorSetting in profile.MonitorSettings) + { + var monitorItem = ViewModel.Monitors.FirstOrDefault(m => m.Monitor.Id == monitorSetting.MonitorId); + if (monitorItem != null) + { + monitorItem.IsSelected = true; + + // Set brightness if included in profile + if (monitorSetting.Brightness.HasValue) + { + monitorItem.IncludeBrightness = true; + monitorItem.Brightness = monitorSetting.Brightness.Value; + } + + // Set color temperature if included in profile + if (monitorSetting.ColorTemperatureVcp.HasValue) + { + monitorItem.IncludeColorTemperature = true; + monitorItem.ColorTemperature = monitorSetting.ColorTemperatureVcp.Value; + } + + // Set contrast if included in profile + if (monitorSetting.Contrast.HasValue) + { + monitorItem.IncludeContrast = true; + monitorItem.Contrast = monitorSetting.Contrast.Value; + } + + // Set volume if included in profile + if (monitorSetting.Volume.HasValue) + { + monitorItem.IncludeVolume = true; + monitorItem.Volume = monitorSetting.Volume.Value; + } + } + } + } + } +} diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/ShellPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/ShellPage.xaml index aae80d05e7..6a64fdea83 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/ShellPage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/ShellPage.xaml @@ -179,6 +179,9 @@ AutomationProperties.AutomationId="SystemToolsNavItem" Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/SystemTools.png}" SelectsOnInvoked="False"> + + + + + + + + Color Picker Product name: Navigation view item name for Color Picker + + Power Display + Product name: Navigation view item name for Power Display + Keyboard Manager Product name: Navigation view item name for Keyboard Manager @@ -5439,6 +5443,9 @@ The break timer font matches the text font. Behavior + + Monitor settings + Light Switch @@ -5508,6 +5515,45 @@ The break timer font matches the text font. Supported applications + + Enable PowerDisplay module to use automatic monitor profile switching + + + + + + Enable PowerDisplay module to use automatic monitor profile switching + + + Apply monitor settings to + + + Automatically switch PowerDisplay profiles when theme changes + + + Enable monitor settings integration + + + Dark mode profile + + + Light mode profile + + + Dark mode profile + + + Profile to apply when switching to dark mode + + + Light mode profile + + + Profile to apply when switching to light mode + + + Open PowerDisplay settings + Select a location @@ -5687,6 +5733,265 @@ The break timer font matches the text font. Learn more + + Power Display + + + A display management utility for brightness and power control + + + Enable Power Display + + + Configuration + + + Toggle Power Display + Dashboard: Label for the PowerDisplay activation hotkey + + + Activation shortcut + Header for the PowerDisplay activation shortcut setting + + + Open Power Display + + + Launch the Power Display utility + + + Launch at startup + + + Automatically start Power Display when Windows starts + + + On + + + Off + + + Restore settings on startup + + + Restore monitor brightness and color temperature when Power Display launches + + + On + + + Off + + + Show system tray icon + + + Choose if PowerDisplay is visible in the system tray + + + Monitor refresh delay + + + Number of seconds to wait after display changes before refreshing monitors. Increase if monitors are not detected after hot-plug. + + + Profiles + + + Quick apply profiles + + + Click a profile button to quickly apply saved monitor settings + + + Add profile + + + Monitors + + + No monitors detected. Ensure your external monitors are connected and powered on. + + + Learn more about Power Display + + + Power Display + + + Power Display provides unified control over display settings across multiple monitors. Adjust brightness, contrast, volume, color temperature, and input source for all connected displays from a single overlay. Supports DDC/CI for external monitors and WMI for laptop displays, with profile support for quick configuration switching and LightSwitch integration for automatic theme-based adjustments. + + + to open the Power Display overlay. + + + **Create profiles** to save your preferred display settings, then configure them in **LightSwitch** to automatically switch profiles when the system theme changes. + + + Open Power Display + + + Display contrast slider + + + Display volume slider + + + Show input source control + + + Show rotation control + + + Show color temperature switcher + + + Show power state control + + + Hide monitor + + + Confirm Color Temperature Change + + + ⚠️ Warning: This is a potentially dangerous operation! + + + Changing the color temperature setting may cause unpredictable results including: + + + • Incorrect display colors +• Display malfunction +• Settings that cannot be reverted + + + Do you want to enable color temperature control for this monitor? + + + Enable + + + Cancel + + + Save + + + Edit Profile + + + Profile name + + + Enter profile name (e.g., 'Gaming Mode', 'Work') + + + Select what monitors and settings to include for this profile + + + Brightness + + + Contrast + + + Volume + + + Color temperature + + + Select.. + + + Profile + + + Delete Profile + + + Are you sure you want to delete '{0}'? + + + Delete + + + Apply profile + + + More profile options + + + Edit profile + + + Delete profile + + + Add new profile + + + Apply + + + More settings + + + Edit + + + Delete + + + Save current settings as new profile + + + Monitor capabilities unavailable + + + This monitor did not report DDC/CI capabilities. Advanced controls may be limited. + + + VCP capabilities + + + DDC/CI VCP codes and supported values (for debugging purposes) + + + View VCP details + + + View VCP details + + + Detected VCP Codes + + + Copy all VCP codes to clipboard + + + Copy VCP codes to clipboard + + + Copy + + + Flyout options + + + Show profile switcher + + + Show or hide the profile switcher button in the Power Display flyout + + + Show identify monitors button + + + Show or hide the identify monitors button in the Power Display flyout + Backup diff --git a/src/settings-ui/Settings.UI/ViewModels/DashboardViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/DashboardViewModel.cs index bfa418ba8b..418d6b5964 100644 --- a/src/settings-ui/Settings.UI/ViewModels/DashboardViewModel.cs +++ b/src/settings-ui/Settings.UI/ViewModels/DashboardViewModel.cs @@ -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 SendConfigMSG { get; } @@ -107,7 +107,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels public DashboardViewModel(ISettingsRepository settingsRepository, Func 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(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(list); } + private ObservableCollection GetModuleItemsPowerDisplay() + { + ISettingsRepository moduleSettingsRepository = SettingsRepository.GetInstance(SettingsUtils.Default); + var settings = moduleSettingsRepository.SettingsConfig; + var list = new List + { + 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(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) diff --git a/src/settings-ui/Settings.UI/ViewModels/LightSwitchViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/LightSwitchViewModel.cs index 6d3c83c8ba..02082d5a0e 100644 --- a/src/settings-ui/Settings.UI/ViewModels/LightSwitchViewModel.cs +++ b/src/settings-ui/Settings.UI/ViewModels/LightSwitchViewModel.cs @@ -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 SearchLocations { get; } = new(); - public LightSwitchViewModel(ISettingsRepository settingsRepository, LightSwitchSettings initialSettings = null, Func ipcMSGCallBackFunc = null) + public LightSwitchViewModel(ISettingsRepository settingsRepository, LightSwitchSettings? initialSettings = null, Func? 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 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 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(); + } + } + + /// + /// 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. + /// + 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(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 _availableProfiles = new ObservableCollection(); + private bool _isPowerDisplayEnabled; + private PowerDisplayProfile? _selectedDarkModeProfile; + private PowerDisplayProfile? _selectedLightModeProfile; + public ICommand ForceLightCommand { get; } public ICommand ForceDarkCommand { get; } diff --git a/src/settings-ui/Settings.UI/ViewModels/MonitorSelectionItem.cs b/src/settings-ui/Settings.UI/ViewModels/MonitorSelectionItem.cs new file mode 100644 index 0000000000..941f669b57 --- /dev/null +++ b/src/settings-ui/Settings.UI/ViewModels/MonitorSelectionItem.cs @@ -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 +{ + /// + /// ViewModel for monitor selection in profile editor + /// + 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)); + } + } +} diff --git a/src/settings-ui/Settings.UI/ViewModels/PowerDisplayViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/PowerDisplayViewModel.cs new file mode 100644 index 0000000000..ce19e37467 --- /dev/null +++ b/src/settings-ui/Settings.UI/ViewModels/PowerDisplayViewModel.cs @@ -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 settingsRepository, ISettingsRepository powerDisplaySettingsRepository, Func 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(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 GetAllHotkeySettings() + { + var hotkeysDict = new Dictionary + { + [ModuleName] = [ActivationShortcut], + }; + + return hotkeysDict; + } + + /// + /// Gets or sets the delay in seconds before refreshing monitors after display changes. + /// + public int MonitorRefreshDelay + { + get => _settings.Properties.MonitorRefreshDelay; + set => SetSettingsProperty(_settings.Properties.MonitorRefreshDelay, value, v => _settings.Properties.MonitorRefreshDelay = v); + } + + private readonly List _monitorRefreshDelayOptions = new List { 1, 2, 3, 5, 10 }; + + public List MonitorRefreshDelayOptions => _monitorRefreshDelayOptions; + + public ObservableCollection 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()); + UnsubscribeFromItemPropertyChanged(e.OldItems?.Cast()); + + HasMonitors = _monitors.Count > 0; + _settings.Properties.Monitors = _monitors.ToList(); + NotifySettingsChanged(); + + // Update TotalMonitorCount for dynamic DisplayName + UpdateTotalMonitorCount(); + } + + /// + /// Update TotalMonitorCount on all monitors for dynamic DisplayName formatting. + /// When multiple monitors exist, DisplayName shows "Name N" format. + /// + 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(); + } + + /// + /// Subscribe to PropertyChanged events for items in the collection + /// + private void SubscribeToItemPropertyChanged(IEnumerable items) + { + if (items != null) + { + foreach (var item in items) + { + item.PropertyChanged += OnMonitorPropertyChanged; + } + } + } + + /// + /// Unsubscribe from PropertyChanged events for items in the collection + /// + private void UnsubscribeFromItemPropertyChanged(IEnumerable items) + { + if (items != null) + { + foreach (var item in items) + { + item.PropertyChanged -= OnMonitorPropertyChanged; + } + } + } + + /// + /// Handle PropertyChanged events from MonitorInfo objects + /// + 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(); + } + } + + /// + /// Signal PowerDisplay.exe that settings have been updated and need to be applied + /// + 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)); + } + + /// + /// Reload monitor list from settings file (called when PowerDisplay.exe signals monitor changes) + /// + private void ReloadMonitorsFromSettings() + { + try + { + Logger.LogInfo("Reloading monitors from settings file"); + + // Read fresh settings from file + var updatedSettings = SettingsUtils.GetSettingsOrDefault(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(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 SendConfigMSG { get; } + + private bool _isEnabled; + private PowerDisplaySettings _settings; + private ObservableCollection _monitors; + private bool _hasMonitors; + + // Profile-related fields + private ObservableCollection _profiles = new ObservableCollection(); + + /// + /// Gets or sets collection of available profiles (for button display) + /// + public ObservableCollection Profiles + { + get => _profiles; + set + { + if (_profiles != value) + { + _profiles = value; + OnPropertyChanged(); + } + } + } + + public void RefreshEnabledState() + { + InitializeEnabledValue(); + OnPropertyChanged(nameof(IsEnabled)); + } + + private bool SetSettingsProperty(T currentValue, T newValue, Action setter, [CallerMemberName] string propertyName = null) + { + if (EqualityComparer.Default.Equals(currentValue, newValue)) + { + return false; + } + + setter(newValue); + OnPropertyChanged(propertyName); + NotifySettingsChanged(); + return true; + } + + /// + /// Load profiles from disk + /// + 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(); + } + } + + /// + /// Apply a profile to monitors + /// + 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}"); + } + } + + /// + /// Create a new profile + /// + 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}"); + } + } + + /// + /// Update an existing profile + /// + 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}"); + } + } + + /// + /// Delete a profile + /// + 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())); + } + } +} diff --git a/src/settings-ui/Settings.UI/ViewModels/ProfileEditorViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/ProfileEditorViewModel.cs new file mode 100644 index 0000000000..57001d7ac7 --- /dev/null +++ b/src/settings-ui/Settings.UI/ViewModels/ProfileEditorViewModel.cs @@ -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 +{ + /// + /// ViewModel for Profile Editor Dialog + /// + public class ProfileEditorViewModel : INotifyPropertyChanged + { + private string _profileName = string.Empty; + private ObservableCollection _monitors; + + public ProfileEditorViewModel(ObservableCollection availableMonitors, string defaultName = "") + { + _profileName = defaultName; + _monitors = new ObservableCollection(); + + // 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 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)); + } + + /// + /// Handle property changes from monitor selection items. + /// Centralizes validation state updates to avoid duplication. + /// + 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)); + } + } + } +}