mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-01-07 21:06:58 +01:00
Compare commits
201 Commits
main
...
copilot/su
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf71ba32b0 | ||
|
|
fbfd57bd93 | ||
|
|
e6b499487a | ||
|
|
5d10bbcaf9 | ||
|
|
2c8aca7dfe | ||
|
|
598629e57b | ||
|
|
1e3cec08e3 | ||
|
|
afaaa76c54 | ||
|
|
d4d475d42a | ||
|
|
bdb47bf534 | ||
|
|
003977a95d | ||
|
|
a7d4324dda | ||
|
|
f86dcb3863 | ||
|
|
5b135b0654 | ||
|
|
98a54dba9b | ||
|
|
ebb1758d73 | ||
|
|
9c35ac90f7 | ||
|
|
90be113e20 | ||
|
|
11565dbf1c | ||
|
|
40a1245b86 | ||
|
|
3fa044d44c | ||
|
|
989d263091 | ||
|
|
d6a851535d | ||
|
|
03088b8a2e | ||
|
|
7cc00fa99e | ||
|
|
3e203649f1 | ||
|
|
08c0944cca | ||
|
|
30da716be3 | ||
|
|
6f5477442b | ||
|
|
5e0909fa36 | ||
|
|
bfc5765530 | ||
|
|
6eeb18b4c8 | ||
|
|
e1c443628a | ||
|
|
734ef8816b | ||
|
|
6d032713aa | ||
|
|
6acd859d43 | ||
|
|
4f4a724d35 | ||
|
|
da0b272fb3 | ||
|
|
2f4f079d97 | ||
|
|
dcf1767c23 | ||
|
|
04de4b8357 | ||
|
|
53a6d45056 | ||
|
|
ab1561bfc4 | ||
|
|
39c10ad039 | ||
|
|
132ed2128e | ||
|
|
06a72f3c54 | ||
|
|
2083fcd143 | ||
|
|
ef9c26dd50 | ||
|
|
a001d5cacd | ||
|
|
dea4cbd045 | ||
|
|
f47abb43e9 | ||
|
|
4557c509e5 | ||
|
|
73dca1b598 | ||
|
|
4c93fe01d0 | ||
|
|
865dd60a83 | ||
|
|
2880b5afce | ||
|
|
9a175df510 | ||
|
|
f07fa4db60 | ||
|
|
19eb78e696 | ||
|
|
ac94b3d9a5 | ||
|
|
570cff4590 | ||
|
|
d3ebebc24c | ||
|
|
5f15229691 | ||
|
|
75f57f53f2 | ||
|
|
a4770a84cf | ||
|
|
87eb7cc07f | ||
|
|
54006a8ef1 | ||
|
|
0fc2fc42d3 | ||
|
|
cffdf72afb | ||
|
|
a05859efc8 | ||
|
|
762a6bce0c | ||
|
|
6aa7e2cdf6 | ||
|
|
ecd8331d51 | ||
|
|
e85797b449 | ||
|
|
b622e6249d | ||
|
|
5b2af47528 | ||
|
|
77a7c04b2e | ||
|
|
4817709fda | ||
|
|
0965f814ce | ||
|
|
d48438571e | ||
|
|
102077a29b | ||
|
|
97560ea6c0 | ||
|
|
0a2b433697 | ||
|
|
db15380fcf | ||
|
|
095ae2bebd | ||
|
|
c093332f84 | ||
|
|
725ac65450 | ||
|
|
0bc59e7101 | ||
|
|
b004fe1445 | ||
|
|
a0f45c444f | ||
|
|
25db00ec45 | ||
|
|
10bdb31a8a | ||
|
|
9654ffde06 | ||
|
|
393b0af104 | ||
|
|
b409f1e4bf | ||
|
|
ab4ad5c940 | ||
|
|
253b29bd11 | ||
|
|
bd9b66afa4 | ||
|
|
dd02ed54e0 | ||
|
|
5bf0a610e8 | ||
|
|
b75db43988 | ||
|
|
b624dd2b03 | ||
|
|
ce9bd1e67e | ||
|
|
0655497762 | ||
|
|
430a41875e | ||
|
|
47638c5c6d | ||
|
|
cf727e8a92 | ||
|
|
fc0ae601a6 | ||
|
|
6c7504d134 | ||
|
|
578a66734a | ||
|
|
6366fa7407 | ||
|
|
f07b4942ef | ||
|
|
e2be06f387 | ||
|
|
f32ee3ea02 | ||
|
|
d9584de585 | ||
|
|
967ff78c93 | ||
|
|
9413b7cc37 | ||
|
|
ea75725ba7 | ||
|
|
3aabc0fcd1 | ||
|
|
c7ffb46f48 | ||
|
|
f729e4ab32 | ||
|
|
a5ea44921c | ||
|
|
4fae32cffe | ||
|
|
700078259b | ||
|
|
9957da6e0f | ||
|
|
9230ba198c | ||
|
|
61e636d1ea | ||
|
|
6f1b336040 | ||
|
|
391f61d4ed | ||
|
|
0bbfc8015a | ||
|
|
fe36b62ec6 | ||
|
|
ae9dd9970c | ||
|
|
bbeea7b2e6 | ||
|
|
589aaf6f3e | ||
|
|
a4e2fe18fe | ||
|
|
1f425f9540 | ||
|
|
12916deca0 | ||
|
|
1c33cf0348 | ||
|
|
7c69874689 | ||
|
|
9b86aef4b3 | ||
|
|
59d0ac58aa | ||
|
|
dac9a3de50 | ||
|
|
6b634ca0d3 | ||
|
|
79c155e422 | ||
|
|
04c1a2cac9 | ||
|
|
42db185274 | ||
|
|
0c43859784 | ||
|
|
8bdd2ffdfd | ||
|
|
be23f2d7fd | ||
|
|
84f8c45733 | ||
|
|
3598c2c126 | ||
|
|
65af62e77a | ||
|
|
a68f31aa59 | ||
|
|
4ff44b382b | ||
|
|
de44da04de | ||
|
|
f5a2235f53 | ||
|
|
738b6696c5 | ||
|
|
649cd8fd5a | ||
|
|
933ca71c6d | ||
|
|
a59ea081b3 | ||
|
|
9c23cbd448 | ||
|
|
c1a82420ed | ||
|
|
b0feeb1f9c | ||
|
|
5fb1183027 | ||
|
|
471cad659f | ||
|
|
2992907142 | ||
|
|
a7e006332a | ||
|
|
15746e8f45 | ||
|
|
d85d109c78 | ||
|
|
fe5edd9c5d | ||
|
|
580651b47a | ||
|
|
8806e4ef2e | ||
|
|
b4ccac5ec2 | ||
|
|
b381472bf7 | ||
|
|
925c97a7f9 | ||
|
|
336234d05b | ||
|
|
8aec939c9d | ||
|
|
d64bb78727 | ||
|
|
b8abff02ac | ||
|
|
fc54172e13 | ||
|
|
a48e999963 | ||
|
|
ad83b5e67f | ||
|
|
f10c9f49e9 | ||
|
|
3f84ccc603 | ||
|
|
55cd6c95b8 | ||
|
|
15e6a762d3 | ||
|
|
f05740b0cb | ||
|
|
5f97f7f222 | ||
|
|
94bc13e703 | ||
|
|
e645a19629 | ||
|
|
4c799b61fc | ||
|
|
83410f1bc8 | ||
|
|
33d5ff26c6 | ||
|
|
d822745c98 | ||
|
|
0b7109dee4 | ||
|
|
ac9fd27095 | ||
|
|
753fecbe9f | ||
|
|
c24b5d97c5 | ||
|
|
5d63ca7a9c | ||
|
|
e90c4273f7 | ||
|
|
e2774eff2d |
111
.github/actions/spell-check/expect.txt
vendored
111
.github/actions/spell-check/expect.txt
vendored
@@ -11,6 +11,7 @@ ACCESSDENIED
|
||||
ACCESSTOKEN
|
||||
acfs
|
||||
ACIE
|
||||
ACR
|
||||
AClient
|
||||
AColumn
|
||||
acrt
|
||||
@@ -92,6 +93,7 @@ asf
|
||||
Ashcraft
|
||||
AShortcut
|
||||
ASingle
|
||||
ASUS
|
||||
ASSOCCHANGED
|
||||
ASSOCF
|
||||
ASSOCSTR
|
||||
@@ -102,6 +104,7 @@ ATRIOX
|
||||
ATX
|
||||
aumid
|
||||
authenticode
|
||||
AUO
|
||||
AUTOBUDDY
|
||||
AUTOCHECKBOX
|
||||
AUTOHIDE
|
||||
@@ -119,6 +122,10 @@ azureaiinference
|
||||
azureinference
|
||||
azureopenai
|
||||
backticks
|
||||
Backlight
|
||||
Badflags
|
||||
Badmode
|
||||
Badparam
|
||||
bbwe
|
||||
BCIE
|
||||
bck
|
||||
@@ -190,7 +197,10 @@ CARETBLINKING
|
||||
Carlseibert
|
||||
CAtl
|
||||
caub
|
||||
CAuthn
|
||||
CAuthz
|
||||
CBN
|
||||
Cds
|
||||
cch
|
||||
CCHDEVICENAME
|
||||
CCHFORMNAME
|
||||
@@ -209,9 +219,11 @@ changecursor
|
||||
CHILDACTIVATE
|
||||
CHILDWINDOW
|
||||
CHOOSEFONT
|
||||
Chunghwa
|
||||
cidl
|
||||
CIELCh
|
||||
cim
|
||||
CImp
|
||||
CImage
|
||||
cla
|
||||
CLASSDC
|
||||
@@ -219,7 +231,7 @@ CLASSNOTAVAILABLE
|
||||
CLEARTYPE
|
||||
clickable
|
||||
clickonce
|
||||
CLIENTEDGE
|
||||
clientedge
|
||||
clientid
|
||||
clientside
|
||||
CLIPBOARDUPDATE
|
||||
@@ -231,6 +243,7 @@ CLSCTX
|
||||
clsids
|
||||
Clusion
|
||||
cmder
|
||||
CMN
|
||||
CMDNOTFOUNDMODULEINTERFACE
|
||||
cmdpal
|
||||
CMIC
|
||||
@@ -246,7 +259,7 @@ codereview
|
||||
Codespaces
|
||||
Coen
|
||||
cognitiveservices
|
||||
COINIT
|
||||
coinit
|
||||
colid
|
||||
colorconv
|
||||
colorformat
|
||||
@@ -285,6 +298,7 @@ Corpor
|
||||
cotaskmem
|
||||
COULDNOT
|
||||
countof
|
||||
Cowait
|
||||
covrun
|
||||
cpcontrols
|
||||
cph
|
||||
@@ -304,11 +318,13 @@ CRECT
|
||||
CRH
|
||||
critsec
|
||||
cropandlock
|
||||
crt
|
||||
Crossdevice
|
||||
csdevkit
|
||||
CSearch
|
||||
CSettings
|
||||
cso
|
||||
CSOT
|
||||
CSRW
|
||||
CStyle
|
||||
cswin
|
||||
@@ -349,11 +365,17 @@ DBPROP
|
||||
DBPROPIDSET
|
||||
DBPROPSET
|
||||
DCBA
|
||||
DCapabilities
|
||||
DCOM
|
||||
DComposition
|
||||
DCR
|
||||
ddc
|
||||
Ddc
|
||||
Ddcci
|
||||
ddcutil
|
||||
DDEIf
|
||||
Deact
|
||||
debouncer
|
||||
debugbreak
|
||||
decryptor
|
||||
Dedup
|
||||
@@ -370,6 +392,7 @@ DEFAULTTOPRIMARY
|
||||
DEFERERASE
|
||||
DEFPUSHBUTTON
|
||||
deinitialization
|
||||
DELA
|
||||
DELETEDKEYIMAGE
|
||||
DELETESCANS
|
||||
DEMOTYPE
|
||||
@@ -399,18 +422,21 @@ DISABLEASACTIONKEY
|
||||
DISABLENOSCROLL
|
||||
diskmgmt
|
||||
DISPLAYCHANGE
|
||||
DISPLAYCONFIG
|
||||
displayconfig
|
||||
DISPLAYFLAGS
|
||||
DISPLAYFREQUENCY
|
||||
displayname
|
||||
DISPLAYORIENTATION
|
||||
Displayport
|
||||
diu
|
||||
divyan
|
||||
Dlg
|
||||
DLGFRAME
|
||||
DLGMODALFRAME
|
||||
dlgmodalframe
|
||||
dlib
|
||||
dllhost
|
||||
dllmain
|
||||
Dmdo
|
||||
DNLEN
|
||||
DONOTROUND
|
||||
DONTVALIDATEPATH
|
||||
@@ -427,6 +453,7 @@ DRAWCLIPBOARD
|
||||
DRAWFRAME
|
||||
drawingcolor
|
||||
dreamsofameaningfullife
|
||||
DREGION
|
||||
drivedetectionwarning
|
||||
DROPFILES
|
||||
DSTINVERT
|
||||
@@ -438,6 +465,7 @@ dutil
|
||||
DVASPECT
|
||||
DVASPECTINFO
|
||||
DVD
|
||||
dvi
|
||||
dvr
|
||||
DVTARGETDEVICE
|
||||
dwflags
|
||||
@@ -457,15 +485,19 @@ DWMWINDOWMAXIMIZEDCHANGE
|
||||
DWORDLONG
|
||||
dworigin
|
||||
dwrite
|
||||
Dxva
|
||||
dxgi
|
||||
eab
|
||||
EAccess
|
||||
easeofaccess
|
||||
ecount
|
||||
Edid
|
||||
edid
|
||||
EDITKEYBOARD
|
||||
EDITSHORTCUTS
|
||||
EDITTEXT
|
||||
EFile
|
||||
EInvalid
|
||||
eep
|
||||
eku
|
||||
emojis
|
||||
ENABLEDELAYEDEXPANSION
|
||||
@@ -475,14 +507,16 @@ ENABLETEMPLATE
|
||||
encodedlaunch
|
||||
encryptor
|
||||
ENDSESSION
|
||||
ENot
|
||||
ENSUREVISIBLE
|
||||
ENTERSIZEMOVE
|
||||
ENTRYW
|
||||
ENU
|
||||
environmentvariables
|
||||
EOAC
|
||||
eoac
|
||||
EPO
|
||||
epu
|
||||
EProvider
|
||||
ERASEBKGND
|
||||
EREOF
|
||||
EResize
|
||||
@@ -536,6 +570,7 @@ fdx
|
||||
FErase
|
||||
fesf
|
||||
FFFF
|
||||
FFh
|
||||
FInc
|
||||
Figma
|
||||
FILEEXPLORER
|
||||
@@ -577,7 +612,9 @@ FORMATDLGORD
|
||||
formatetc
|
||||
FORPARSING
|
||||
foundrylocal
|
||||
FPrimary
|
||||
FRAMECHANGED
|
||||
Framechanged
|
||||
FRestore
|
||||
frm
|
||||
FROMTOUCH
|
||||
@@ -637,6 +674,8 @@ gwl
|
||||
GWLP
|
||||
GWLSTYLE
|
||||
hangeul
|
||||
Hann
|
||||
Hantai
|
||||
Hanzi
|
||||
Hardlines
|
||||
hardlinks
|
||||
@@ -658,6 +697,8 @@ HCRYPTPROV
|
||||
hcursor
|
||||
hcwhite
|
||||
hdc
|
||||
hdmi
|
||||
HDMI
|
||||
hdr
|
||||
hdrop
|
||||
hdwwiz
|
||||
@@ -694,6 +735,7 @@ HKPD
|
||||
HKU
|
||||
HMD
|
||||
hmenu
|
||||
HMON
|
||||
hmodule
|
||||
hmonitor
|
||||
homies
|
||||
@@ -711,6 +753,7 @@ hotkeys
|
||||
hotlight
|
||||
hotspot
|
||||
HPAINTBUFFER
|
||||
HPhysical
|
||||
HRAWINPUT
|
||||
hredraw
|
||||
hres
|
||||
@@ -721,6 +764,7 @@ hsb
|
||||
HSCROLL
|
||||
hsi
|
||||
HSpeed
|
||||
HSync
|
||||
HTCLIENT
|
||||
hthumbnail
|
||||
HTOUCHINPUT
|
||||
@@ -730,6 +774,7 @@ HVal
|
||||
HValue
|
||||
Hvci
|
||||
hwb
|
||||
HWP
|
||||
HWHEEL
|
||||
HWINEVENTHOOK
|
||||
hwnd
|
||||
@@ -786,6 +831,7 @@ INITTOLOGFONTSTRUCT
|
||||
INLINEPREFIX
|
||||
inlines
|
||||
Inno
|
||||
Innolux
|
||||
INPC
|
||||
inproc
|
||||
INPUTHARDWARE
|
||||
@@ -827,6 +873,7 @@ istep
|
||||
ith
|
||||
ITHUMBNAIL
|
||||
IUI
|
||||
IVO
|
||||
IUWP
|
||||
IWIC
|
||||
jfif
|
||||
@@ -838,6 +885,7 @@ jpnime
|
||||
Jsons
|
||||
jsonval
|
||||
jxr
|
||||
Kantai
|
||||
keybd
|
||||
KEYBDDATA
|
||||
KEYBDINPUT
|
||||
@@ -859,6 +907,7 @@ KILLFOCUS
|
||||
killrunner
|
||||
kmph
|
||||
kvp
|
||||
KVM
|
||||
Kybd
|
||||
LARGEICON
|
||||
lastcodeanalysissucceeded
|
||||
@@ -878,6 +927,8 @@ LEFTTEXT
|
||||
LError
|
||||
LEVELID
|
||||
LExit
|
||||
Lenovo
|
||||
LGD
|
||||
lhwnd
|
||||
LIBFUZZER
|
||||
LIBID
|
||||
@@ -982,6 +1033,7 @@ MAPTOSAMESHORTCUT
|
||||
MAPVK
|
||||
MARKDOWNPREVIEWHANDLERCPP
|
||||
MAXIMIZEBOX
|
||||
Maximizebox
|
||||
MAXSHORTCUTSIZE
|
||||
maxversiontested
|
||||
mber
|
||||
@@ -992,6 +1044,8 @@ MDL
|
||||
mdtext
|
||||
mdtxt
|
||||
mdwn
|
||||
Mccs
|
||||
mccs
|
||||
meme
|
||||
mcp
|
||||
memicmp
|
||||
@@ -1013,6 +1067,7 @@ mikeclayton
|
||||
mindaro
|
||||
Minimizable
|
||||
MINIMIZEBOX
|
||||
Minimizebox
|
||||
MINIMIZEEND
|
||||
MINIMIZESTART
|
||||
MINMAXINFO
|
||||
@@ -1032,6 +1087,7 @@ MODALFRAME
|
||||
MODESPRUNED
|
||||
MONITORENUMPROC
|
||||
MONITORINFO
|
||||
Monitorinfo
|
||||
MONITORINFOEX
|
||||
MONITORINFOEXW
|
||||
monitorinfof
|
||||
@@ -1072,9 +1128,10 @@ MSLLHOOKSTRUCT
|
||||
Mso
|
||||
msrc
|
||||
msstore
|
||||
mstsc
|
||||
mswhql
|
||||
msvcp
|
||||
MT
|
||||
mstsc
|
||||
MTND
|
||||
MULTIPLEUSE
|
||||
multizone
|
||||
@@ -1090,6 +1147,7 @@ MVVMTK
|
||||
MWBEx
|
||||
MYICON
|
||||
NAMECHANGE
|
||||
Nanjing
|
||||
namespaceanddescendants
|
||||
nao
|
||||
NCACTIVATE
|
||||
@@ -1158,6 +1216,7 @@ NOMCX
|
||||
NOMINMAX
|
||||
NOMIRRORBITMAP
|
||||
NOMOVE
|
||||
Nomove
|
||||
NONANTIALIASED
|
||||
nonclient
|
||||
NONCLIENTMETRICSW
|
||||
@@ -1179,6 +1238,7 @@ NORMALUSER
|
||||
NOSEARCH
|
||||
NOSENDCHANGING
|
||||
NOSIZE
|
||||
Nosize
|
||||
NOTHOUSANDS
|
||||
NOTICKS
|
||||
NOTIFICATIONSDLL
|
||||
@@ -1186,9 +1246,11 @@ NOTIFYICONDATA
|
||||
NOTIFYICONDATAW
|
||||
NOTIMPL
|
||||
NOTOPMOST
|
||||
Notopmost
|
||||
NOTRACK
|
||||
NOTSRCCOPY
|
||||
NOTSRCERASE
|
||||
Notupdated
|
||||
notwindows
|
||||
NOTXORPEN
|
||||
nowarn
|
||||
@@ -1230,10 +1292,9 @@ OPENFILENAME
|
||||
openrdp
|
||||
opensource
|
||||
openxmlformats
|
||||
ollama
|
||||
onnx
|
||||
openurl
|
||||
OPTIMIZEFORINVOKE
|
||||
Optronics
|
||||
ORPHANEDDIALOGTITLE
|
||||
ORSCANS
|
||||
oss
|
||||
@@ -1269,6 +1330,7 @@ PATINVERT
|
||||
PATPAINT
|
||||
pbc
|
||||
pbi
|
||||
PBP
|
||||
PBlob
|
||||
pbrush
|
||||
pcb
|
||||
@@ -1283,6 +1345,7 @@ PDBs
|
||||
PDEVMODE
|
||||
pdisp
|
||||
PDLL
|
||||
pdmodels
|
||||
pdo
|
||||
pdto
|
||||
pdtobj
|
||||
@@ -1305,6 +1368,7 @@ pguid
|
||||
phbm
|
||||
phbmp
|
||||
phicon
|
||||
PHL
|
||||
Photoshop
|
||||
phwnd
|
||||
pici
|
||||
@@ -1336,6 +1400,8 @@ Pomodoro
|
||||
Popups
|
||||
POPUPWINDOW
|
||||
POSITIONITEM
|
||||
powerdisplay
|
||||
POWERDISPLAYMODULEINTERFACE
|
||||
POWERRENAMECONTEXTMENU
|
||||
powerrenameinput
|
||||
POWERRENAMETEST
|
||||
@@ -1390,6 +1456,7 @@ projectname
|
||||
PROPERTYKEY
|
||||
Propset
|
||||
PROPVARIANT
|
||||
prot
|
||||
PRTL
|
||||
prvpane
|
||||
psapi
|
||||
@@ -1417,12 +1484,15 @@ PTOKEN
|
||||
PToy
|
||||
ptstr
|
||||
pui
|
||||
pvct
|
||||
PWAs
|
||||
pwcs
|
||||
PWSTR
|
||||
pwsz
|
||||
pwtd
|
||||
QDC
|
||||
qdc
|
||||
QDS
|
||||
qit
|
||||
QITAB
|
||||
QITABENT
|
||||
@@ -1447,7 +1517,6 @@ RAWPATH
|
||||
rbhid
|
||||
rclsid
|
||||
RCZOOMIT
|
||||
remotedesktop
|
||||
rdp
|
||||
RDW
|
||||
READMODE
|
||||
@@ -1476,6 +1545,7 @@ remappings
|
||||
REMAPSUCCESSFUL
|
||||
REMAPUNSUCCESSFUL
|
||||
Remotable
|
||||
remotedesktop
|
||||
remoteip
|
||||
Removelnk
|
||||
renamable
|
||||
@@ -1549,6 +1619,7 @@ scrollviewer
|
||||
SDDL
|
||||
SDKDDK
|
||||
sdns
|
||||
Sdr
|
||||
searchterm
|
||||
SEARCHUI
|
||||
secondaryclickaction
|
||||
@@ -1703,6 +1774,7 @@ STARTUPINFOW
|
||||
startupscreen
|
||||
STATFLAG
|
||||
STATICEDGE
|
||||
Staticedge
|
||||
STATSTG
|
||||
stdafx
|
||||
STDAPI
|
||||
@@ -1745,7 +1817,7 @@ SVGIO
|
||||
svgz
|
||||
SVSI
|
||||
SWFO
|
||||
SWP
|
||||
swp
|
||||
SWPNOSIZE
|
||||
SWPNOZORDER
|
||||
SWRESTORE
|
||||
@@ -1765,6 +1837,7 @@ syskeydown
|
||||
SYSKEYUP
|
||||
SYSLIB
|
||||
SYSMENU
|
||||
Sysmenu
|
||||
systemai
|
||||
SYSTEMAPPS
|
||||
SYSTEMMODAL
|
||||
@@ -1804,7 +1877,9 @@ THEMECHANGED
|
||||
themeresources
|
||||
THH
|
||||
THICKFRAME
|
||||
Thickframe
|
||||
THISCOMPONENT
|
||||
Tianma
|
||||
throughs
|
||||
TILEDWINDOW
|
||||
TILLSON
|
||||
@@ -1891,7 +1966,7 @@ unzoom
|
||||
UOffset
|
||||
UOI
|
||||
UPDATENOW
|
||||
UPDATEREGISTRY
|
||||
updateregistry
|
||||
updown
|
||||
UPGRADINGPRODUCTCODE
|
||||
upscaling
|
||||
@@ -1918,6 +1993,9 @@ vcamp
|
||||
vcenter
|
||||
vcgtq
|
||||
VCINSTALLDIR
|
||||
Vcp
|
||||
vcp
|
||||
vcpname
|
||||
Vcpkg
|
||||
VCRT
|
||||
vcruntime
|
||||
@@ -1930,7 +2008,10 @@ VERIFYCONTEXT
|
||||
VERSIONINFO
|
||||
VERTRES
|
||||
VERTSIZE
|
||||
VESA
|
||||
vesa
|
||||
VFT
|
||||
Vga
|
||||
vget
|
||||
vgetq
|
||||
viewmodels
|
||||
@@ -1960,6 +2041,7 @@ VSM
|
||||
vso
|
||||
vsonline
|
||||
VSpeed
|
||||
VSync
|
||||
vstemplate
|
||||
vstest
|
||||
VSTHRD
|
||||
@@ -2001,7 +2083,7 @@ winapi
|
||||
winappsdk
|
||||
windir
|
||||
WINDOWCREATED
|
||||
WINDOWEDGE
|
||||
windowedge
|
||||
WINDOWINFO
|
||||
WINDOWNAME
|
||||
WINDOWPLACEMENT
|
||||
@@ -2025,7 +2107,7 @@ WINL
|
||||
winlogon
|
||||
winmd
|
||||
winml
|
||||
WINNT
|
||||
winnt
|
||||
winres
|
||||
winrt
|
||||
winsdk
|
||||
@@ -2042,6 +2124,7 @@ WKSG
|
||||
Wlkr
|
||||
wmain
|
||||
Wman
|
||||
wmi
|
||||
WMI
|
||||
WMICIM
|
||||
wmimgmt
|
||||
|
||||
@@ -203,6 +203,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",
|
||||
@@ -371,6 +376,8 @@
|
||||
"UnitsNet.dll",
|
||||
"UtfUnknown.dll",
|
||||
"Wpf.Ui.dll",
|
||||
"WmiLight.dll",
|
||||
"WmiLight.Native.dll",
|
||||
"Shmuelie.WinRTServer.dll",
|
||||
"ToolGood.Words.Pinyin.dll"
|
||||
],
|
||||
|
||||
@@ -91,6 +91,7 @@
|
||||
<PackageVersion Include="NLog.Extensions.Logging" Version="5.3.8" />
|
||||
<PackageVersion Include="NLog.Schema" Version="5.2.8" />
|
||||
<PackageVersion Include="OpenAI" Version="2.5.0" />
|
||||
<PackageVersion Include="Polly.Core" Version="8.6.5" />
|
||||
<PackageVersion Include="ReverseMarkdown" Version="4.1.0" />
|
||||
<PackageVersion Include="RtfPipe" Version="2.0.7677.4303" />
|
||||
<PackageVersion Include="ScipBe.Common.Office.OneNote" Version="3.0.1" />
|
||||
@@ -102,6 +103,7 @@
|
||||
<PackageVersion Include="StyleCop.Analyzers" Version="1.2.0-beta.556" />
|
||||
<!-- Package System.CodeDom added as a hack for being able to exclude the runtime assets so they don't conflict with 8.0.1. This is a dependency of System.Management but the 8.0.1 version wasn't published to nuget. -->
|
||||
<PackageVersion Include="System.CodeDom" Version="9.0.10" />
|
||||
<PackageVersion Include="System.Collections.Immutable" Version="9.0.0" />
|
||||
<PackageVersion Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
|
||||
<PackageVersion Include="System.ComponentModel.Composition" Version="9.0.10" />
|
||||
<PackageVersion Include="System.Configuration.ConfigurationManager" Version="9.0.10" />
|
||||
@@ -131,6 +133,7 @@
|
||||
<PackageVersion Include="UnitsNet" Version="5.56.0" />
|
||||
<PackageVersion Include="UTF.Unknown" Version="2.6.0" />
|
||||
<PackageVersion Include="WinUIEx" Version="2.8.0" />
|
||||
<PackageVersion Include="WmiLight" Version="6.14.0" />
|
||||
<PackageVersion Include="WPF-UI" Version="3.0.5" />
|
||||
<PackageVersion Include="WyHash" Version="1.0.5" />
|
||||
<PackageVersion Include="WixToolset.Heat" Version="5.0.2" />
|
||||
|
||||
32
NOTICE.md
32
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
|
||||
@@ -667,6 +667,23 @@
|
||||
<Deploy />
|
||||
</Project>
|
||||
</Folder>
|
||||
<Folder Name="/modules/PowerDisplay/">
|
||||
<Project Path="src/modules/powerdisplay/PowerDisplay.Lib/PowerDisplay.Lib.csproj">
|
||||
<Platform Solution="*|ARM64" Project="ARM64" />
|
||||
<Platform Solution="*|x64" Project="x64" />
|
||||
</Project>
|
||||
<Project Path="src/modules/powerdisplay/PowerDisplay/PowerDisplay.csproj">
|
||||
<Platform Solution="*|ARM64" Project="ARM64" />
|
||||
<Platform Solution="*|x64" Project="x64" />
|
||||
</Project>
|
||||
<Project Path="src/modules/powerdisplay/PowerDisplayModuleInterface/PowerDisplayModuleInterface.vcxproj" Id="d1234567-8901-2345-6789-abcdef012345" />
|
||||
</Folder>
|
||||
<Folder Name="/modules/PowerDisplay/Tests/">
|
||||
<Project Path="src/modules/powerdisplay/PowerDisplay.Lib.UnitTests/PowerDisplay.Lib.UnitTests.csproj">
|
||||
<Platform Solution="*|ARM64" Project="ARM64" />
|
||||
<Platform Solution="*|x64" Project="x64" />
|
||||
</Project>
|
||||
</Folder>
|
||||
<Folder Name="/modules/MeasureTool/">
|
||||
<Project Path="src/modules/MeasureTool/MeasureToolCore/PowerToys.MeasureToolCore.vcxproj" Id="54a93af7-60c7-4f6c-99d2-fbb1f75f853a">
|
||||
<BuildDependency Project="src/common/Display/Display.vcxproj" />
|
||||
|
||||
1580
doc/devdocs/modules/powerdisplay/design.md
Normal file
1580
doc/devdocs/modules/powerdisplay/design.md
Normal file
File diff suppressed because it is too large
Load Diff
223
doc/devdocs/modules/powerdisplay/mccsParserDesign.md
Normal file
223
doc/devdocs/modules/powerdisplay/mccsParserDesign.md
Normal file
@@ -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<char>
|
||||
├── ApplySegment() → void
|
||||
│ ├── ParseHexList() → List<byte>
|
||||
│ ├── ParseVcpEntries() → Dictionary<byte, VcpCodeInfo>
|
||||
│ └── 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<char>` 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<ParseError> 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)
|
||||
@@ -1549,7 +1549,7 @@ UINT __stdcall TerminateProcessesCA(MSIHANDLE hInstall)
|
||||
}
|
||||
processes.resize(bytes / sizeof(processes[0]));
|
||||
|
||||
std::array<std::wstring_view, 42> processesToTerminate = {
|
||||
std::array<std::wstring_view, 43> 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",
|
||||
|
||||
29
installer/PowerToysSetupVNext/PowerDisplay.wxs
Normal file
29
installer/PowerToysSetupVNext/PowerDisplay.wxs
Normal file
@@ -0,0 +1,29 @@
|
||||
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs"
|
||||
xmlns:util="http://wixtoolset.org/schemas/v4/wxs/util" >
|
||||
|
||||
<?include $(sys.CURRENTDIR)\Common.wxi?>
|
||||
|
||||
<?define PowerDisplayAssetsFiles=?>
|
||||
<?define PowerDisplayAssetsFilesPath=$(var.BinDir)WinUI3Apps\Assets\PowerDisplay?>
|
||||
|
||||
<Fragment>
|
||||
<!-- Power Display -->
|
||||
<DirectoryRef Id="WinUI3AppsAssetsFolder">
|
||||
<Directory Id="PowerDisplayAssetsInstallFolder" Name="PowerDisplay" />
|
||||
</DirectoryRef>
|
||||
<DirectoryRef Id="PowerDisplayAssetsInstallFolder" FileSource="$(var.PowerDisplayAssetsFilesPath)">
|
||||
<!-- Generated by generateFileComponents.ps1 -->
|
||||
<!--PowerDisplayAssetsFiles_Component_Def-->
|
||||
</DirectoryRef>
|
||||
|
||||
<ComponentGroup Id="PowerDisplayComponentGroup">
|
||||
<Component Id="RemovePowerDisplayFolder" Guid="B8F2E3A5-72C1-4A2D-9B3F-8E5D7C6A4F9B" Directory="PowerDisplayAssetsInstallFolder" >
|
||||
<RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components">
|
||||
<RegistryValue Type="string" Name="RemovePowerDisplayFolder" Value="" KeyPath="yes"/>
|
||||
</RegistryKey>
|
||||
<RemoveFolder Id="RemoveFolderPowerDisplayAssetsFolder" Directory="PowerDisplayAssetsInstallFolder" On="uninstall"/>
|
||||
</Component>
|
||||
</ComponentGroup>
|
||||
|
||||
</Fragment>
|
||||
</Wix>
|
||||
@@ -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
|
||||
<Compile Include="KeyboardManager.wxs" />
|
||||
<Compile Include="Peek.wxs" />
|
||||
<Compile Include="PowerRename.wxs" />
|
||||
<Compile Include="PowerDisplay.wxs" />
|
||||
<Compile Include="DscResources.wxs" />
|
||||
<Compile Include="RegistryPreview.wxs" />
|
||||
<Compile Include="Run.wxs" />
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
<ComponentGroupRef Id="LightSwitchComponentGroup" />
|
||||
<ComponentGroupRef Id="PeekComponentGroup" />
|
||||
<ComponentGroupRef Id="PowerRenameComponentGroup" />
|
||||
<ComponentGroupRef Id="PowerDisplayComponentGroup" />
|
||||
<ComponentGroupRef Id="RegistryPreviewComponentGroup" />
|
||||
<ComponentGroupRef Id="RunComponentGroup" />
|
||||
<ComponentGroupRef Id="SettingsComponentGroup" />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -32,6 +32,10 @@ namespace winrt::PowerToys::GPOWrapper::implementation
|
||||
{
|
||||
return static_cast<GpoRuleConfigured>(powertoys_gpo::getConfiguredLightSwitchEnabledValue());
|
||||
}
|
||||
GpoRuleConfigured GPOWrapper::GetConfiguredPowerDisplayEnabledValue()
|
||||
{
|
||||
return static_cast<GpoRuleConfigured>(powertoys_gpo::getConfiguredPowerDisplayEnabledValue());
|
||||
}
|
||||
GpoRuleConfigured GPOWrapper::GetConfiguredFancyZonesEnabledValue()
|
||||
{
|
||||
return static_cast<GpoRuleConfigured>(powertoys_gpo::getConfiguredFancyZonesEnabledValue());
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -30,6 +30,7 @@ namespace ManagedCommon
|
||||
PowerRename,
|
||||
PowerLauncher,
|
||||
PowerAccent,
|
||||
PowerDisplay,
|
||||
RegistryPreview,
|
||||
MeasureTool,
|
||||
ShortcutGuide,
|
||||
|
||||
@@ -247,4 +247,36 @@ 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::ApplyColorTemperaturePowerDisplayEvent()
|
||||
{
|
||||
return CommonSharedConstants::APPLY_COLOR_TEMPERATURE_POWER_DISPLAY_EVENT;
|
||||
}
|
||||
hstring Constants::ApplyProfilePowerDisplayEvent()
|
||||
{
|
||||
return CommonSharedConstants::APPLY_PROFILE_POWER_DISPLAY_EVENT;
|
||||
}
|
||||
hstring Constants::PowerDisplaySendSettingsTelemetryEvent()
|
||||
{
|
||||
return CommonSharedConstants::POWER_DISPLAY_SEND_SETTINGS_TELEMETRY_EVENT;
|
||||
}
|
||||
hstring Constants::HotkeyUpdatedPowerDisplayEvent()
|
||||
{
|
||||
return CommonSharedConstants::HOTKEY_UPDATED_POWER_DISPLAY_EVENT;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,14 @@ 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 ApplyColorTemperaturePowerDisplayEvent();
|
||||
static hstring ApplyProfilePowerDisplayEvent();
|
||||
static hstring PowerDisplaySendSettingsTelemetryEvent();
|
||||
static hstring HotkeyUpdatedPowerDisplayEvent();
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -62,6 +62,14 @@ 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 ApplyColorTemperaturePowerDisplayEvent();
|
||||
static String ApplyProfilePowerDisplayEvent();
|
||||
static String PowerDisplaySendSettingsTelemetryEvent();
|
||||
static String HotkeyUpdatedPowerDisplayEvent();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,6 +148,20 @@ 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 APPLY_COLOR_TEMPERATURE_POWER_DISPLAY_EVENT[] = L"Local\\PowerToysPowerDisplay-ApplyColorTemperatureEvent-4b7e9f2a-3c6d-5a8e-7f1b-9d2e4c6a8b0d";
|
||||
const wchar_t APPLY_PROFILE_POWER_DISPLAY_EVENT[] = L"Local\\PowerToysPowerDisplay-ApplyProfileEvent-6e8a3c9d-4f7b-5d2e-8a1c-3e9f7b6d2a5c";
|
||||
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";
|
||||
|
||||
// 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";
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -148,6 +148,16 @@
|
||||
<decimal value="0" />
|
||||
</disabledValue>
|
||||
</policy>
|
||||
<policy name="ConfigureEnabledUtilityPowerDisplay" class="Both" displayName="$(string.ConfigureEnabledUtilityPowerDisplay)" explainText="$(string.ConfigureEnabledUtilityDescription)" key="Software\Policies\PowerToys" valueName="ConfigureEnabledUtilityPowerDisplay">
|
||||
<parentCategory ref="PowerToys" />
|
||||
<supportedOn ref="SUPPORTED_POWERTOYS_0_95_0" />
|
||||
<enabledValue>
|
||||
<decimal value="1" />
|
||||
</enabledValue>
|
||||
<disabledValue>
|
||||
<decimal value="0" />
|
||||
</disabledValue>
|
||||
</policy>
|
||||
<policy name="ConfigureEnabledUtilityEnvironmentVariables" class="Both" displayName="$(string.ConfigureEnabledUtilityEnvironmentVariables)" explainText="$(string.ConfigureEnabledUtilityDescription)" key="Software\Policies\PowerToys" valueName="ConfigureEnabledUtilityEnvironmentVariables">
|
||||
<parentCategory ref="PowerToys" />
|
||||
<supportedOn ref="SUPPORTED_POWERTOYS_0_75_0" />
|
||||
|
||||
@@ -247,6 +247,7 @@ If you don't configure this policy, the user will be able to control the setting
|
||||
<string id="ConfigureEnabledUtilityCmdPal">CmdPal: Configure enabled state</string>
|
||||
<string id="ConfigureEnabledUtilityCropAndLock">Crop And Lock: Configure enabled state</string>
|
||||
<string id="ConfigureEnabledUtilityLightSwitch">Light Switch: Configure enabled state</string>
|
||||
<string id="ConfigureEnabledUtilityPowerDisplay">PowerDisplay: Configure enabled state</string>
|
||||
<string id="ConfigureEnabledUtilityEnvironmentVariables">Environment Variables: Configure enabled state</string>
|
||||
<string id="ConfigureEnabledUtilityFancyZones">FancyZones: Configure enabled state</string>
|
||||
<string id="ConfigureEnabledUtilityFileLocksmith">File Locksmith: Configure enabled state</string>
|
||||
|
||||
@@ -241,6 +241,46 @@ void LightSwitchSettings::LoadSettings()
|
||||
NotifyObservers(SettingId::ChangeApps);
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include <LightSwitchUtils.h>
|
||||
#include "ThemeScheduler.h"
|
||||
#include <ThemeHelper.h>
|
||||
#include <common/interop/shared_constants.h>
|
||||
|
||||
void ApplyTheme(bool shouldBeLight);
|
||||
|
||||
@@ -37,7 +38,7 @@ void LightSwitchStateManager::OnTick(int currentMinutes)
|
||||
}
|
||||
}
|
||||
|
||||
// Called when manual override is triggered
|
||||
// Called when manual override is triggered (via hotkey)
|
||||
void LightSwitchStateManager::OnManualOverride()
|
||||
{
|
||||
std::lock_guard<std::mutex> 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();
|
||||
@@ -264,7 +269,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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,741 @@
|
||||
// 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;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for MccsCapabilitiesParser class.
|
||||
/// Tests parsing of DDC/CI MCCS capabilities strings using real-world examples.
|
||||
/// Reference: https://www.ddcutil.com/cap_u3011_verbose_output/
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class MccsCapabilitiesParserTests
|
||||
{
|
||||
// Real capabilities string from Dell U3011 monitor
|
||||
// Source: https://www.ddcutil.com/cap_u3011_verbose_output/
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// 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.VisualStudio.TestTools.UnitTesting;
|
||||
using PowerDisplay.Common.Models;
|
||||
using PowerDisplay.Common.Utils;
|
||||
|
||||
namespace PowerDisplay.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for MonitorMatchingHelper class.
|
||||
/// Tests monitor key generation and matching logic.
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class MonitorMatchingHelperTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void GetMonitorKey_WithMonitor_ReturnsId()
|
||||
{
|
||||
// Arrange
|
||||
var monitor = new Monitor { Id = "DDC_GSM5C6D_1", Name = "LG Monitor" };
|
||||
|
||||
// Act
|
||||
var result = MonitorMatchingHelper.GetMonitorKey(monitor);
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual("DDC_GSM5C6D_1", result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void GetMonitorKey_NullMonitor_ReturnsEmptyString()
|
||||
{
|
||||
// Act
|
||||
var result = MonitorMatchingHelper.GetMonitorKey(null);
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(string.Empty, result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void GetMonitorKey_EmptyId_ReturnsEmptyString()
|
||||
{
|
||||
// Arrange
|
||||
var monitor = new Monitor { Id = string.Empty, Name = "Display Name" };
|
||||
|
||||
// Act
|
||||
var result = MonitorMatchingHelper.GetMonitorKey(monitor);
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(string.Empty, result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void AreMonitorsSame_SameId_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var monitor1 = new Monitor { Id = "DDC_GSM5C6D_1", Name = "Monitor 1" };
|
||||
var monitor2 = new Monitor { Id = "DDC_GSM5C6D_1", Name = "Monitor 2" };
|
||||
|
||||
// Act
|
||||
var result = MonitorMatchingHelper.AreMonitorsSame(monitor1, monitor2);
|
||||
|
||||
// Assert
|
||||
Assert.IsTrue(result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void AreMonitorsSame_DifferentId_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var monitor1 = new Monitor { Id = "DDC_GSM5C6D_1", Name = "Monitor 1" };
|
||||
var monitor2 = new Monitor { Id = "DDC_GSM5C6D_2", Name = "Monitor 2" };
|
||||
|
||||
// Act
|
||||
var result = MonitorMatchingHelper.AreMonitorsSame(monitor1, monitor2);
|
||||
|
||||
// Assert
|
||||
Assert.IsFalse(result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void AreMonitorsSame_NullMonitor_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var monitor1 = new Monitor { Id = "DDC_GSM5C6D_1", Name = "Monitor 1" };
|
||||
|
||||
// Act
|
||||
var result = MonitorMatchingHelper.AreMonitorsSame(monitor1, null!);
|
||||
|
||||
// Assert
|
||||
Assert.IsFalse(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<!-- Copyright (c) Microsoft Corporation. All rights reserved. -->
|
||||
<!-- Licensed under the MIT License. See LICENSE file in the project root for license information. -->
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Import Project="..\..\..\Common.Dotnet.CsWinRT.props" />
|
||||
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>PowerDisplay.UnitTests</RootNamespace>
|
||||
<Platforms>x64;ARM64</Platforms>
|
||||
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
|
||||
<OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\tests\PowerDisplay.Lib.UnitTests\</OutputPath>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Hide build log files from Solution Explorer -->
|
||||
<None Remove="*.log" />
|
||||
<None Remove="*.binlog" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MSTest" />
|
||||
<PackageReference Include="Moq" />
|
||||
<PackageReference Include="System.CodeDom">
|
||||
<!-- This package is a transitive dependency, but we need to set it here so we can exclude the assets,
|
||||
so it doesn't conflict with the dll coming from .NET SDK. -->
|
||||
<ExcludeAssets>runtime</ExcludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="System.Diagnostics.EventLog">
|
||||
<!-- This package is a transitive dependency, but we need to set it here so we can exclude the assets,
|
||||
so it doesn't conflict with the dll coming from .NET SDK. -->
|
||||
<ExcludeAssets>runtime</ExcludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\PowerDisplay.Lib\PowerDisplay.Lib.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,658 @@
|
||||
// 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;
|
||||
using RECT = PowerDisplay.Common.Drivers.Rect;
|
||||
|
||||
namespace PowerDisplay.Common.Drivers.DDC
|
||||
{
|
||||
/// <summary>
|
||||
/// DDC/CI monitor controller for controlling external monitors
|
||||
/// </summary>
|
||||
public partial class DdcCiController : IMonitorController, IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents a candidate monitor discovered during Phase 1 of monitor enumeration.
|
||||
/// </summary>
|
||||
/// <param name="Handle">Physical monitor handle for DDC/CI communication</param>
|
||||
/// <param name="PhysicalMonitor">Native physical monitor structure with description</param>
|
||||
/// <param name="MonitorInfo">Display info from QueryDisplayConfig (HardwareId, FriendlyName, MonitorNumber)</param>
|
||||
private readonly record struct CandidateMonitor(
|
||||
IntPtr Handle,
|
||||
PHYSICAL_MONITOR PhysicalMonitor,
|
||||
MonitorDisplayInfo MonitorInfo);
|
||||
|
||||
/// <summary>
|
||||
/// Delay between retry attempts for DDC/CI operations (in milliseconds)
|
||||
/// </summary>
|
||||
private const int RetryDelayMs = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Retry pipeline for getting capabilities string length (3 retries).
|
||||
/// </summary>
|
||||
private static readonly ResiliencePipeline<uint> CapabilitiesLengthRetryPipeline =
|
||||
new ResiliencePipelineBuilder<uint>()
|
||||
.AddRetry(new RetryStrategyOptions<uint>
|
||||
{
|
||||
MaxRetryAttempts = 2, // 2 retries = 3 total attempts
|
||||
Delay = TimeSpan.FromMilliseconds(RetryDelayMs),
|
||||
ShouldHandle = new PredicateBuilder<uint>().HandleResult(len => len == 0),
|
||||
OnRetry = static args =>
|
||||
{
|
||||
Logger.LogWarning($"[Retry] GetCapabilitiesStringLength returned invalid result on attempt {args.AttemptNumber + 1}, retrying...");
|
||||
return default;
|
||||
},
|
||||
})
|
||||
.Build();
|
||||
|
||||
/// <summary>
|
||||
/// Retry pipeline for getting capabilities string (5 retries).
|
||||
/// </summary>
|
||||
private static readonly ResiliencePipeline<string?> CapabilitiesStringRetryPipeline =
|
||||
new ResiliencePipelineBuilder<string?>()
|
||||
.AddRetry(new RetryStrategyOptions<string?>
|
||||
{
|
||||
MaxRetryAttempts = 4, // 4 retries = 5 total attempts
|
||||
Delay = TimeSpan.FromMilliseconds(RetryDelayMs),
|
||||
ShouldHandle = new PredicateBuilder<string?>().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";
|
||||
|
||||
/// <summary>
|
||||
/// Get monitor brightness using VCP code 0x10
|
||||
/// </summary>
|
||||
public async Task<VcpFeatureValue> GetBrightnessAsync(Monitor monitor, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(monitor);
|
||||
return await GetVcpFeatureAsync(monitor, VcpCodeBrightness, "Brightness", cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set monitor brightness using VCP code 0x10
|
||||
/// </summary>
|
||||
public Task<MonitorOperationResult> SetBrightnessAsync(Monitor monitor, int brightness, CancellationToken cancellationToken = default)
|
||||
=> SetVcpFeatureAsync(monitor, NativeConstants.VcpCodeBrightness, brightness, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Set monitor contrast
|
||||
/// </summary>
|
||||
public Task<MonitorOperationResult> SetContrastAsync(Monitor monitor, int contrast, CancellationToken cancellationToken = default)
|
||||
=> SetVcpFeatureAsync(monitor, NativeConstants.VcpCodeContrast, contrast, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Set monitor volume
|
||||
/// </summary>
|
||||
public Task<MonitorOperationResult> SetVolumeAsync(Monitor monitor, int volume, CancellationToken cancellationToken = default)
|
||||
=> SetVcpFeatureAsync(monitor, NativeConstants.VcpCodeVolume, volume, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// </summary>
|
||||
public async Task<VcpFeatureValue> GetColorTemperatureAsync(Monitor monitor, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(monitor);
|
||||
return await GetVcpFeatureAsync(monitor, VcpCodeSelectColorPreset, "Color temperature", cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set monitor color temperature using VCP code 0x14 (Select Color Preset)
|
||||
/// </summary>
|
||||
public Task<MonitorOperationResult> SetColorTemperatureAsync(Monitor monitor, int colorTemperature, CancellationToken cancellationToken = default)
|
||||
=> SetVcpFeatureAsync(monitor, VcpCodeSelectColorPreset, colorTemperature, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Get current input source using VCP code 0x60
|
||||
/// Returns the raw VCP value (e.g., 0x11 for HDMI-1)
|
||||
/// </summary>
|
||||
public async Task<VcpFeatureValue> GetInputSourceAsync(Monitor monitor, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(monitor);
|
||||
return await GetVcpFeatureAsync(monitor, VcpCodeInputSource, "Input source", cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set input source using VCP code 0x60
|
||||
/// </summary>
|
||||
public Task<MonitorOperationResult> SetInputSourceAsync(Monitor monitor, int inputSource, CancellationToken cancellationToken = default)
|
||||
=> SetVcpFeatureAsync(monitor, VcpCodeInputSource, inputSource, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Get monitor capabilities string with retry logic.
|
||||
/// Uses cached CapabilitiesRaw if available to avoid slow I2C operations.
|
||||
/// </summary>
|
||||
public async Task<string> GetCapabilitiesStringAsync(Monitor monitor, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(monitor);
|
||||
|
||||
// Check if capabilities are already cached
|
||||
if (!string.IsNullOrEmpty(monitor.CapabilitiesRaw))
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Try to get capabilities string from monitor handle.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// </summary>
|
||||
public async Task<IEnumerable<Monitor>> 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<Monitor>();
|
||||
}
|
||||
|
||||
var candidateMonitors = await CollectCandidateMonitorsAsync(
|
||||
monitorHandles, allMonitorDisplayInfo, cancellationToken);
|
||||
|
||||
if (candidateMonitors.Count == 0)
|
||||
{
|
||||
return Enumerable.Empty<Monitor>();
|
||||
}
|
||||
|
||||
// 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<Monitor>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enumerate all logical monitor handles using Win32 API.
|
||||
/// </summary>
|
||||
private List<IntPtr> EnumerateMonitorHandles()
|
||||
{
|
||||
var handles = new List<IntPtr>();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get GDI device name for a monitor handle (e.g., "\\.\DISPLAY1").
|
||||
/// </summary>
|
||||
private unsafe string? GetGdiDeviceName(IntPtr hMonitor)
|
||||
{
|
||||
var monitorInfo = new MONITORINFOEX { CbSize = (uint)sizeof(MONITORINFOEX) };
|
||||
if (GetMonitorInfo(hMonitor, ref monitorInfo))
|
||||
{
|
||||
return monitorInfo.GetDeviceName();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private async Task<List<CandidateMonitor>> CollectCandidateMonitorsAsync(
|
||||
List<IntPtr> monitorHandles,
|
||||
Dictionary<string, MonitorDisplayInfo> allMonitorDisplayInfo,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var candidates = new List<CandidateMonitor>();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private async Task<(CandidateMonitor Candidate, DdcCiValidationResult Result)[]> FetchCapabilitiesInParallelAsync(
|
||||
List<CandidateMonitor> candidates,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Logger.LogInfo($"DDC: Phase 2 - Fetching capabilities for {candidates.Count} monitors in parallel");
|
||||
|
||||
var tasks = candidates.Select(candidate =>
|
||||
Task.Run(
|
||||
() => (Candidate: candidate, Result: DdcCiNative.FetchCapabilities(candidate.Handle)),
|
||||
cancellationToken));
|
||||
|
||||
var results = await Task.WhenAll(tasks);
|
||||
|
||||
Logger.LogInfo($"DDC: Phase 2 completed - Got results for {results.Length} monitors");
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 3: Create Monitor objects for valid DDC/CI monitors.
|
||||
/// A monitor is valid if it has capabilities with brightness support.
|
||||
/// </summary>
|
||||
private List<Monitor> CreateValidMonitors(
|
||||
(CandidateMonitor Candidate, DdcCiValidationResult Result)[] fetchResults)
|
||||
{
|
||||
var monitors = new List<Monitor>();
|
||||
var newHandleMap = new Dictionary<string, IntPtr>();
|
||||
|
||||
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 brightness (always supported for DDC/CI monitors)
|
||||
InitializeBrightness(monitor, candidate.Handle);
|
||||
|
||||
monitors.Add(monitor);
|
||||
newHandleMap[monitor.Id] = candidate.Handle;
|
||||
|
||||
Logger.LogInfo($"DDC: Added monitor {monitor.Id} with {monitor.VcpCapabilitiesInfo?.SupportedVcpCodes.Count ?? 0} VCP codes");
|
||||
}
|
||||
|
||||
_handleManager.UpdateHandleMap(newHandleMap);
|
||||
return monitors;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initialize input source value for a monitor using VCP 0x60.
|
||||
/// </summary>
|
||||
private static void InitializeInputSource(Monitor monitor, IntPtr handle)
|
||||
{
|
||||
if (GetVCPFeatureAndVCPFeatureReply(handle, VcpCodeInputSource, IntPtr.Zero, out uint current, out uint _))
|
||||
{
|
||||
monitor.CurrentInputSource = (int)current;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initialize color temperature value for a monitor using VCP 0x14.
|
||||
/// </summary>
|
||||
private static void InitializeColorTemperature(Monitor monitor, IntPtr handle)
|
||||
{
|
||||
if (GetVCPFeatureAndVCPFeatureReply(handle, VcpCodeSelectColorPreset, IntPtr.Zero, out uint current, out uint _))
|
||||
{
|
||||
monitor.CurrentColorTemperature = (int)current;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initialize brightness value for a monitor using VCP 0x10.
|
||||
/// </summary>
|
||||
private static void InitializeBrightness(Monitor monitor, IntPtr handle)
|
||||
{
|
||||
if (GetVCPFeatureAndVCPFeatureReply(handle, VcpCodeBrightness, IntPtr.Zero, out uint current, out uint max))
|
||||
{
|
||||
var brightnessInfo = new VcpFeatureValue((int)current, 0, (int)max);
|
||||
monitor.CurrentBrightness = brightnessInfo.ToPercentage();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update monitor capability flags based on parsed VCP capabilities.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="hMonitor">Handle to the monitor</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Array of valid physical monitors, or null if failed after retries</returns>
|
||||
private async Task<PHYSICAL_MONITOR[]?> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generic method to get VCP feature value with optional logging.
|
||||
/// </summary>
|
||||
/// <param name="monitor">Monitor to query</param>
|
||||
/// <param name="vcpCode">VCP code to read</param>
|
||||
/// <param name="featureName">Optional feature name for logging (e.g., "color temperature", "input source")</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
private async Task<VcpFeatureValue> GetVcpFeatureAsync(
|
||||
Monitor monitor,
|
||||
byte vcpCode,
|
||||
string? featureName = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await Task.Run(
|
||||
() =>
|
||||
{
|
||||
if (monitor.Handle == IntPtr.Zero)
|
||||
{
|
||||
return VcpFeatureValue.Invalid;
|
||||
}
|
||||
|
||||
if (GetVCPFeatureAndVCPFeatureReply(monitor.Handle, vcpCode, IntPtr.Zero, out uint current, out uint max))
|
||||
{
|
||||
return new VcpFeatureValue((int)current, 0, (int)max);
|
||||
}
|
||||
|
||||
return VcpFeatureValue.Invalid;
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generic method to set VCP feature value directly.
|
||||
/// </summary>
|
||||
private Task<MonitorOperationResult> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
// 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 ManagedCommon;
|
||||
using static PowerDisplay.Common.Drivers.NativeConstants;
|
||||
using static PowerDisplay.Common.Drivers.PInvoke;
|
||||
|
||||
// Type aliases for Windows API naming conventions compatibility
|
||||
using LUID = PowerDisplay.Common.Drivers.Luid;
|
||||
|
||||
#pragma warning disable SA1649 // File name should match first type name - Multiple related types for DDC/CI
|
||||
#pragma warning disable SA1402 // File may only contain a single type - Related DDC/CI types grouped together
|
||||
|
||||
namespace PowerDisplay.Common.Drivers.DDC
|
||||
{
|
||||
/// <summary>
|
||||
/// DDC/CI validation result containing both validation status and cached capabilities data.
|
||||
/// This allows reusing capabilities data retrieved during validation, avoiding duplicate I2C calls.
|
||||
/// </summary>
|
||||
public struct DdcCiValidationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the monitor has a valid DDC/CI connection with brightness support.
|
||||
/// </summary>
|
||||
public bool IsValid { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the raw capabilities string retrieved during validation.
|
||||
/// Null if retrieval failed.
|
||||
/// </summary>
|
||||
public string? CapabilitiesString { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the parsed VCP capabilities info retrieved during validation.
|
||||
/// Null if parsing failed.
|
||||
/// </summary>
|
||||
public Models.VcpCapabilities? VcpCapabilitiesInfo { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether capabilities retrieval was attempted.
|
||||
/// True means the result is from an actual attempt (success or failure).
|
||||
/// </summary>
|
||||
public bool WasAttempted { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DdcCiValidationResult"/> struct.
|
||||
/// </summary>
|
||||
public DdcCiValidationResult(bool isValid, string? capabilitiesString = null, Models.VcpCapabilities? vcpCapabilitiesInfo = null, bool wasAttempted = true)
|
||||
{
|
||||
IsValid = isValid;
|
||||
CapabilitiesString = capabilitiesString;
|
||||
VcpCapabilitiesInfo = vcpCapabilitiesInfo;
|
||||
WasAttempted = wasAttempted;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets an invalid validation result with no cached data.
|
||||
/// </summary>
|
||||
public static DdcCiValidationResult Invalid => new(false, null, null, true);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a result indicating validation was not attempted yet.
|
||||
/// </summary>
|
||||
public static DdcCiValidationResult NotAttempted => new(false, null, null, false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DDC/CI native API wrapper
|
||||
/// </summary>
|
||||
public static class DdcCiNative
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="hPhysicalMonitor">Physical monitor handle</param>
|
||||
/// <returns>Validation result with capabilities data (or failure status)</returns>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Try to get capabilities string from a physical monitor handle.
|
||||
/// </summary>
|
||||
/// <param name="hPhysicalMonitor">Physical monitor handle</param>
|
||||
/// <returns>Capabilities string, or null if failed</returns>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets GDI device name for a source (e.g., "\\.\DISPLAY1").
|
||||
/// </summary>
|
||||
/// <param name="adapterId">Adapter ID</param>
|
||||
/// <param name="sourceId">Source ID</param>
|
||||
/// <returns>GDI device name, or null if retrieval fails</returns>
|
||||
private static unsafe string? GetSourceGdiDeviceName(LUID adapterId, uint sourceId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var sourceName = new DISPLAYCONFIG_SOURCE_DEVICE_NAME
|
||||
{
|
||||
Header = new DISPLAYCONFIG_DEVICE_INFO_HEADER
|
||||
{
|
||||
Type = DisplayconfigDeviceInfoGetSourceName,
|
||||
Size = (uint)sizeof(DISPLAYCONFIG_SOURCE_DEVICE_NAME),
|
||||
AdapterId = adapterId,
|
||||
Id = sourceId,
|
||||
},
|
||||
};
|
||||
|
||||
var result = DisplayConfigGetDeviceInfo(ref sourceName);
|
||||
if (result == 0)
|
||||
{
|
||||
return sourceName.GetViewGdiDeviceName();
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is not OutOfMemoryException)
|
||||
{
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets friendly name, hardware ID, and device path for a monitor target.
|
||||
/// </summary>
|
||||
/// <param name="adapterId">Adapter ID</param>
|
||||
/// <param name="targetId">Target ID</param>
|
||||
/// <returns>Tuple of (friendlyName, hardwareId, devicePath), any may be null if retrieval fails</returns>
|
||||
private static unsafe (string? FriendlyName, string? HardwareId, string? DevicePath) GetTargetDeviceInfo(LUID adapterId, uint targetId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var deviceName = new DISPLAYCONFIG_TARGET_DEVICE_NAME
|
||||
{
|
||||
Header = new DISPLAYCONFIG_DEVICE_INFO_HEADER
|
||||
{
|
||||
Type = DisplayconfigDeviceInfoGetTargetName,
|
||||
Size = (uint)sizeof(DISPLAYCONFIG_TARGET_DEVICE_NAME),
|
||||
AdapterId = adapterId,
|
||||
Id = targetId,
|
||||
},
|
||||
};
|
||||
|
||||
var result = DisplayConfigGetDeviceInfo(ref 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 hardware ID from EDID data
|
||||
var manufacturerId = deviceName.EdidManufactureId;
|
||||
var manufactureCode = ConvertManufactureIdToString(manufacturerId);
|
||||
var productCode = deviceName.EdidProductCodeId.ToString("X4", System.Globalization.CultureInfo.InvariantCulture);
|
||||
var hardwareId = $"{manufactureCode}{productCode}";
|
||||
|
||||
return (friendlyName, hardwareId, devicePath);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is not OutOfMemoryException)
|
||||
{
|
||||
}
|
||||
|
||||
return (null, null, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts manufacturer ID to 3-character manufacturer code
|
||||
/// </summary>
|
||||
/// <param name="manufacturerId">Manufacturer ID</param>
|
||||
/// <returns>3-character manufacturer code</returns>
|
||||
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}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets complete information for all monitors, keyed by GDI device name (e.g., "\\.\DISPLAY1").
|
||||
/// This allows reliable matching with GetMonitorInfo results.
|
||||
/// </summary>
|
||||
/// <returns>Dictionary keyed by GDI device name containing monitor information</returns>
|
||||
public static unsafe Dictionary<string, MonitorDisplayInfo> GetAllMonitorDisplayInfo()
|
||||
{
|
||||
var monitorInfo = new Dictionary<string, MonitorDisplayInfo>(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 DISPLAYCONFIG_PATH_INFO[pathCount];
|
||||
var modes = new DISPLAYCONFIG_MODE_INFO[modeCount];
|
||||
|
||||
// Query display configuration using fixed pointer
|
||||
fixed (DISPLAYCONFIG_PATH_INFO* pathsPtr = paths)
|
||||
{
|
||||
fixed (DISPLAYCONFIG_MODE_INFO* 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, hardware ID, device path)
|
||||
var (friendlyName, hardwareId, 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,
|
||||
HardwareId = hardwareId ?? 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Monitor display information structure
|
||||
/// </summary>
|
||||
public struct MonitorDisplayInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the monitor device path (e.g., "\\?\DISPLAY#DELA1D8#...").
|
||||
/// This is unique per target and used as the primary key.
|
||||
/// </summary>
|
||||
public string DevicePath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public string GdiDeviceName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the friendly display name from EDID.
|
||||
/// </summary>
|
||||
public string FriendlyName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the hardware ID derived from EDID manufacturer and product code.
|
||||
/// </summary>
|
||||
public string HardwareId { get; set; }
|
||||
|
||||
public LUID AdapterId { get; set; }
|
||||
|
||||
public uint TargetId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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.)
|
||||
/// </summary>
|
||||
public int MonitorNumber { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
// 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
|
||||
{
|
||||
/// <summary>
|
||||
/// Helper class for discovering and creating monitor objects
|
||||
/// </summary>
|
||||
public class MonitorDiscoveryHelper
|
||||
{
|
||||
public MonitorDiscoveryHelper()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get physical monitors for a logical monitor.
|
||||
/// Filters out any monitors with NULL handles (Windows API bug workaround).
|
||||
/// </summary>
|
||||
/// <param name="hMonitor">Handle to the logical monitor</param>
|
||||
/// <param name="hasNullHandles">Output: true if any NULL handles were filtered out</param>
|
||||
/// <returns>Array of valid physical monitors, or null if API call failed</returns>
|
||||
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<PHYSICAL_MONITOR>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="physicalMonitor">Physical monitor structure with handle and description</param>
|
||||
/// <param name="monitorInfo">Display info from QueryDisplayConfig (HardwareId, FriendlyName, MonitorNumber)</param>
|
||||
internal Monitor? CreateMonitorFromPhysical(
|
||||
PHYSICAL_MONITOR physicalMonitor,
|
||||
MonitorDisplayInfo monitorInfo)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Get hardware ID and friendly name directly from MonitorDisplayInfo
|
||||
string edidId = monitorInfo.HardwareId ?? 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Manages physical monitor handles - reuse, cleanup, and validation
|
||||
/// </summary>
|
||||
public partial class PhysicalMonitorHandleManager : IDisposable
|
||||
{
|
||||
// Mapping: monitorId -> physical handle (thread-safe)
|
||||
private readonly ConcurrentDictionary<string, IntPtr> _monitorIdToHandleMap = new();
|
||||
private readonly object _handleLock = new();
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Update the handle mapping with new handles
|
||||
/// </summary>
|
||||
public void UpdateHandleMap(Dictionary<string, IntPtr> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clean up handles that are no longer in use.
|
||||
/// Called within lock context. Optimized to O(n) using HashSet lookup.
|
||||
/// </summary>
|
||||
private void CleanupUnusedHandles(Dictionary<string, IntPtr> newHandles)
|
||||
{
|
||||
if (_monitorIdToHandleMap.IsEmpty)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Build HashSet of handles that will be reused (O(m))
|
||||
var reusedHandles = new HashSet<IntPtr>(newHandles.Values);
|
||||
|
||||
// Find handles to destroy: in old map but not reused (O(n) with O(1) lookup)
|
||||
var handlesToDestroy = _monitorIdToHandleMap.Values
|
||||
.Where(h => h != IntPtr.Zero && !reusedHandles.Contains(h))
|
||||
.ToList();
|
||||
|
||||
// Destroy unused handles
|
||||
foreach (var handle in handlesToDestroy)
|
||||
{
|
||||
try
|
||||
{
|
||||
DestroyPhysicalMonitor(handle);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Silently ignore cleanup failures
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
// Silently ignore cleanup failures
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_monitorIdToHandleMap.Clear();
|
||||
_disposed = true;
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
// 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.Drivers
|
||||
{
|
||||
/// <summary>
|
||||
/// Windows API constant definitions
|
||||
/// </summary>
|
||||
public static class NativeConstants
|
||||
{
|
||||
/// <summary>
|
||||
/// VCP code: Brightness (0x10)
|
||||
/// Standard VESA MCCS brightness control.
|
||||
/// This is the ONLY brightness code used by PowerDisplay.
|
||||
/// </summary>
|
||||
public const byte VcpCodeBrightness = 0x10;
|
||||
|
||||
/// <summary>
|
||||
/// VCP code: Contrast (0x12)
|
||||
/// Standard VESA MCCS contrast control.
|
||||
/// </summary>
|
||||
public const byte VcpCodeContrast = 0x12;
|
||||
|
||||
/// <summary>
|
||||
/// VCP code: Audio Speaker Volume (0x62)
|
||||
/// Standard VESA MCCS volume control for monitors with built-in speakers.
|
||||
/// </summary>
|
||||
public const byte VcpCodeVolume = 0x62;
|
||||
|
||||
/// <summary>
|
||||
/// VCP code: Audio mute (0x8D)
|
||||
/// </summary>
|
||||
public const byte VcpCodeMute = 0x8D;
|
||||
|
||||
/// <summary>
|
||||
/// VCP code: Gamma correction (0x72)
|
||||
/// </summary>
|
||||
public const byte VcpCodeGamma = 0x72;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public const byte VcpCodeSelectColorPreset = 0x14;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public const byte VcpCodeInputSource = 0x60;
|
||||
|
||||
/// <summary>
|
||||
/// VCP code: VCP version
|
||||
/// </summary>
|
||||
public const byte VcpCodeVcpVersion = 0xDF;
|
||||
|
||||
/// <summary>
|
||||
/// VCP code: New control value
|
||||
/// </summary>
|
||||
public const byte VcpCodeNewControlValue = 0x02;
|
||||
|
||||
/// <summary>
|
||||
/// Display device attached to desktop
|
||||
/// </summary>
|
||||
public const uint DisplayDeviceAttachedToDesktop = 0x00000001;
|
||||
|
||||
/// <summary>
|
||||
/// Multi-monitor primary display
|
||||
/// </summary>
|
||||
public const uint DisplayDeviceMultiDriver = 0x00000002;
|
||||
|
||||
/// <summary>
|
||||
/// Primary device
|
||||
/// </summary>
|
||||
public const uint DisplayDevicePrimaryDevice = 0x00000004;
|
||||
|
||||
/// <summary>
|
||||
/// Mirroring driver
|
||||
/// </summary>
|
||||
public const uint DisplayDeviceMirroringDriver = 0x00000008;
|
||||
|
||||
/// <summary>
|
||||
/// VGA compatible
|
||||
/// </summary>
|
||||
public const uint DisplayDeviceVgaCompatible = 0x00000010;
|
||||
|
||||
/// <summary>
|
||||
/// Removable device
|
||||
/// </summary>
|
||||
public const uint DisplayDeviceRemovable = 0x00000020;
|
||||
|
||||
/// <summary>
|
||||
/// Get device interface name
|
||||
/// </summary>
|
||||
public const uint EddGetDeviceInterfaceName = 0x00000001;
|
||||
|
||||
/// <summary>
|
||||
/// Primary monitor
|
||||
/// </summary>
|
||||
public const uint MonitorinfoFPrimary = 0x00000001;
|
||||
|
||||
/// <summary>
|
||||
/// Query display config: only active paths
|
||||
/// </summary>
|
||||
public const uint QdcOnlyActivePaths = 0x00000002;
|
||||
|
||||
/// <summary>
|
||||
/// Query display config: all paths
|
||||
/// </summary>
|
||||
public const uint QdcAllPaths = 0x00000001;
|
||||
|
||||
/// <summary>
|
||||
/// Set display config: apply
|
||||
/// </summary>
|
||||
public const uint SdcApply = 0x00000080;
|
||||
|
||||
/// <summary>
|
||||
/// Set display config: use supplied display config
|
||||
/// </summary>
|
||||
public const uint SdcUseSuppliedDisplayConfig = 0x00000020;
|
||||
|
||||
/// <summary>
|
||||
/// Set display config: save to database
|
||||
/// </summary>
|
||||
public const uint SdcSaveToDatabase = 0x00000200;
|
||||
|
||||
/// <summary>
|
||||
/// Set display config: topology supplied
|
||||
/// </summary>
|
||||
public const uint SdcTopologySupplied = 0x00000010;
|
||||
|
||||
/// <summary>
|
||||
/// Set display config: allow path order changes
|
||||
/// </summary>
|
||||
public const uint SdcAllowPathOrderChanges = 0x00002000;
|
||||
|
||||
/// <summary>
|
||||
/// Get source name (GDI device name like "\\.\DISPLAY1")
|
||||
/// </summary>
|
||||
public const uint DisplayconfigDeviceInfoGetSourceName = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Get target name (monitor friendly name and hardware ID)
|
||||
/// </summary>
|
||||
public const uint DisplayconfigDeviceInfoGetTargetName = 2;
|
||||
|
||||
/// <summary>
|
||||
/// Get SDR white level
|
||||
/// </summary>
|
||||
public const uint DisplayconfigDeviceInfoGetSdrWhiteLevel = 7;
|
||||
|
||||
/// <summary>
|
||||
/// Get advanced color information
|
||||
/// </summary>
|
||||
public const uint DisplayconfigDeviceInfoGetAdvancedColorInfo = 9;
|
||||
|
||||
/// <summary>
|
||||
/// Set SDR white level (custom)
|
||||
/// </summary>
|
||||
public const uint DisplayconfigDeviceInfoSetSdrWhiteLevel = 0xFFFFFFEE;
|
||||
|
||||
/// <summary>
|
||||
/// Path active
|
||||
/// </summary>
|
||||
public const uint DisplayconfigPathActive = 0x00000001;
|
||||
|
||||
/// <summary>
|
||||
/// Path mode index invalid
|
||||
/// </summary>
|
||||
public const uint DisplayconfigPathModeIdxInvalid = 0xFFFFFFFF;
|
||||
|
||||
/// <summary>
|
||||
/// COM initialization: multithreaded
|
||||
/// </summary>
|
||||
public const uint CoinitMultithreaded = 0x0;
|
||||
|
||||
/// <summary>
|
||||
/// RPC authentication level: connect
|
||||
/// </summary>
|
||||
public const uint RpcCAuthnLevelConnect = 2;
|
||||
|
||||
/// <summary>
|
||||
/// RPC impersonation level: impersonate
|
||||
/// </summary>
|
||||
public const uint RpcCImpLevelImpersonate = 3;
|
||||
|
||||
/// <summary>
|
||||
/// RPC authentication service: Win NT
|
||||
/// </summary>
|
||||
public const uint RpcCAuthnWinnt = 10;
|
||||
|
||||
/// <summary>
|
||||
/// RPC authorization service: none
|
||||
/// </summary>
|
||||
public const uint RpcCAuthzNone = 0;
|
||||
|
||||
/// <summary>
|
||||
/// RPC authentication level: call
|
||||
/// </summary>
|
||||
public const uint RpcCAuthnLevelCall = 3;
|
||||
|
||||
/// <summary>
|
||||
/// EOAC: none
|
||||
/// </summary>
|
||||
public const uint EoacNone = 0;
|
||||
|
||||
/// <summary>
|
||||
/// WMI flag: forward only
|
||||
/// </summary>
|
||||
public const long WbemFlagForwardOnly = 0x20;
|
||||
|
||||
/// <summary>
|
||||
/// WMI flag: return immediately
|
||||
/// </summary>
|
||||
public const long WbemFlagReturnImmediately = 0x10;
|
||||
|
||||
/// <summary>
|
||||
/// WMI flag: connect use max wait
|
||||
/// </summary>
|
||||
public const long WbemFlagConnectUseMaxWait = 0x80;
|
||||
|
||||
/// <summary>
|
||||
/// Success
|
||||
/// </summary>
|
||||
public const int ErrorSuccess = 0;
|
||||
|
||||
/// <summary>
|
||||
/// Insufficient buffer
|
||||
/// </summary>
|
||||
public const int ErrorInsufficientBuffer = 122;
|
||||
|
||||
/// <summary>
|
||||
/// Invalid parameter
|
||||
/// </summary>
|
||||
public const int ErrorInvalidParameter = 87;
|
||||
|
||||
/// <summary>
|
||||
/// Access denied
|
||||
/// </summary>
|
||||
public const int ErrorAccessDenied = 5;
|
||||
|
||||
/// <summary>
|
||||
/// General failure
|
||||
/// </summary>
|
||||
public const int ErrorGenFailure = 31;
|
||||
|
||||
/// <summary>
|
||||
/// Unsupported VCP code
|
||||
/// </summary>
|
||||
public const int ErrorGraphicsDdcciVcpNotSupported = -1071243251;
|
||||
|
||||
/// <summary>
|
||||
/// Infinite wait
|
||||
/// </summary>
|
||||
public const uint Infinite = 0xFFFFFFFF;
|
||||
|
||||
/// <summary>
|
||||
/// User message
|
||||
/// </summary>
|
||||
public const uint WmUser = 0x0400;
|
||||
|
||||
/// <summary>
|
||||
/// Output technology: HDMI
|
||||
/// </summary>
|
||||
public const uint DisplayconfigOutputTechnologyHdmi = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Output technology: DVI
|
||||
/// </summary>
|
||||
public const uint DisplayconfigOutputTechnologyDvi = 4;
|
||||
|
||||
/// <summary>
|
||||
/// Output technology: DisplayPort
|
||||
/// </summary>
|
||||
public const uint DisplayconfigOutputTechnologyDisplayportExternal = 6;
|
||||
|
||||
/// <summary>
|
||||
/// Output technology: internal
|
||||
/// </summary>
|
||||
public const uint DisplayconfigOutputTechnologyInternal = 0x80000000;
|
||||
|
||||
/// <summary>
|
||||
/// HDR minimum SDR white level (nits)
|
||||
/// </summary>
|
||||
public const int HdrMinSdrWhiteLevel = 80;
|
||||
|
||||
/// <summary>
|
||||
/// HDR maximum SDR white level (nits)
|
||||
/// </summary>
|
||||
public const int HdrMaxSdrWhiteLevel = 480;
|
||||
|
||||
/// <summary>
|
||||
/// SDR white level conversion factor
|
||||
/// </summary>
|
||||
public const int SdrWhiteLevelFactor = 80;
|
||||
|
||||
/// <summary>
|
||||
/// Retrieve the current settings for the display device.
|
||||
/// </summary>
|
||||
public const int EnumCurrentSettings = -1;
|
||||
|
||||
/// <summary>
|
||||
/// Retrieve the settings for the display device that are stored in the registry.
|
||||
/// </summary>
|
||||
public const int EnumRegistrySettings = -2;
|
||||
|
||||
/// <summary>
|
||||
/// The display is in the natural orientation of the device.
|
||||
/// </summary>
|
||||
public const int DmdoDefault = 0;
|
||||
|
||||
/// <summary>
|
||||
/// The display is rotated 90 degrees (measured clockwise) from its natural orientation.
|
||||
/// </summary>
|
||||
public const int Dmdo90 = 1;
|
||||
|
||||
/// <summary>
|
||||
/// The display is rotated 180 degrees (measured clockwise) from its natural orientation.
|
||||
/// </summary>
|
||||
public const int Dmdo180 = 2;
|
||||
|
||||
/// <summary>
|
||||
/// The display is rotated 270 degrees (measured clockwise) from its natural orientation.
|
||||
/// </summary>
|
||||
public const int Dmdo270 = 3;
|
||||
|
||||
// ==================== DEVMODE field flags ====================
|
||||
|
||||
/// <summary>
|
||||
/// DmDisplayOrientation field is valid.
|
||||
/// </summary>
|
||||
public const int DmDisplayOrientation = 0x00000080;
|
||||
|
||||
/// <summary>
|
||||
/// DmPelsWidth field is valid.
|
||||
/// </summary>
|
||||
public const int DmPelsWidth = 0x00080000;
|
||||
|
||||
/// <summary>
|
||||
/// DmPelsHeight field is valid.
|
||||
/// </summary>
|
||||
public const int DmPelsHeight = 0x00100000;
|
||||
|
||||
// ==================== ChangeDisplaySettings flags ====================
|
||||
|
||||
/// <summary>
|
||||
/// The settings change is temporary. Not saved to registry.
|
||||
/// </summary>
|
||||
public const uint CdsUpdateregistry = 0x00000001;
|
||||
|
||||
/// <summary>
|
||||
/// Test the graphics mode but don't actually set it.
|
||||
/// </summary>
|
||||
public const uint CdsTest = 0x00000002;
|
||||
|
||||
/// <summary>
|
||||
/// The mode is fullscreen.
|
||||
/// </summary>
|
||||
public const uint CdsFullscreen = 0x00000004;
|
||||
|
||||
/// <summary>
|
||||
/// The settings apply to all users.
|
||||
/// </summary>
|
||||
public const uint CdsGlobal = 0x00000008;
|
||||
|
||||
/// <summary>
|
||||
/// Set the primary display.
|
||||
/// </summary>
|
||||
public const uint CdsSetPrimary = 0x00000010;
|
||||
|
||||
/// <summary>
|
||||
/// Reset the mode after a dynamic mode change.
|
||||
/// </summary>
|
||||
public const uint CdsReset = 0x40000000;
|
||||
|
||||
/// <summary>
|
||||
/// Don't reset the mode.
|
||||
/// </summary>
|
||||
public const uint CdsNoreset = 0x10000000;
|
||||
|
||||
// ==================== ChangeDisplaySettings result codes ====================
|
||||
|
||||
/// <summary>
|
||||
/// The settings change was successful.
|
||||
/// </summary>
|
||||
public const int DispChangeSuccessful = 0;
|
||||
|
||||
/// <summary>
|
||||
/// The computer must be restarted for the graphics mode to work.
|
||||
/// </summary>
|
||||
public const int DispChangeRestart = 1;
|
||||
|
||||
/// <summary>
|
||||
/// The display driver failed the specified graphics mode.
|
||||
/// </summary>
|
||||
public const int DispChangeFailed = -1;
|
||||
|
||||
/// <summary>
|
||||
/// The graphics mode is not supported.
|
||||
/// </summary>
|
||||
public const int DispChangeBadmode = -2;
|
||||
|
||||
/// <summary>
|
||||
/// Unable to write settings to the registry.
|
||||
/// </summary>
|
||||
public const int DispChangeNotupdated = -3;
|
||||
|
||||
/// <summary>
|
||||
/// An invalid set of flags was passed in.
|
||||
/// </summary>
|
||||
public const int DispChangeBadflags = -4;
|
||||
|
||||
/// <summary>
|
||||
/// An invalid parameter was passed in.
|
||||
/// </summary>
|
||||
public const int DispChangeBadparam = -5;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace PowerDisplay.Common.Drivers;
|
||||
|
||||
/// <summary>
|
||||
/// Native delegate type definitions
|
||||
/// </summary>
|
||||
public static class NativeDelegates
|
||||
{
|
||||
/// <summary>
|
||||
/// Monitor enumeration procedure delegate
|
||||
/// </summary>
|
||||
/// <param name="hMonitor">Monitor handle</param>
|
||||
/// <param name="hdcMonitor">Monitor device context</param>
|
||||
/// <param name="lprcMonitor">Pointer to monitor rectangle</param>
|
||||
/// <param name="dwData">User data</param>
|
||||
/// <returns>True to continue enumeration</returns>
|
||||
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
|
||||
public delegate bool MonitorEnumProc(IntPtr hMonitor, IntPtr hdcMonitor, IntPtr lprcMonitor, IntPtr dwData);
|
||||
|
||||
/// <summary>
|
||||
/// Thread start routine delegate
|
||||
/// </summary>
|
||||
/// <param name="lpParameter">Thread parameter</param>
|
||||
/// <returns>Thread exit code</returns>
|
||||
public delegate uint ThreadStartRoutine(IntPtr lpParameter);
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
// 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;
|
||||
|
||||
#pragma warning disable SA1649 // File name should match first type name - Multiple related P/Invoke structures
|
||||
|
||||
namespace PowerDisplay.Common.Drivers
|
||||
{
|
||||
/// <summary>
|
||||
/// Physical monitor structure for DDC/CI
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||
public unsafe struct PhysicalMonitor
|
||||
{
|
||||
/// <summary>
|
||||
/// Physical monitor handle
|
||||
/// </summary>
|
||||
public IntPtr HPhysicalMonitor;
|
||||
|
||||
/// <summary>
|
||||
/// Physical monitor description string - fixed buffer for LibraryImport compatibility
|
||||
/// </summary>
|
||||
public fixed ushort SzPhysicalMonitorDescription[128];
|
||||
|
||||
/// <summary>
|
||||
/// Helper method to get description as string
|
||||
/// </summary>
|
||||
public readonly string GetDescription()
|
||||
{
|
||||
fixed (ushort* ptr = SzPhysicalMonitorDescription)
|
||||
{
|
||||
return new string((char*)ptr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rectangle structure
|
||||
/// </summary>
|
||||
[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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Monitor information extended structure
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||
public unsafe struct MonitorInfoEx
|
||||
{
|
||||
/// <summary>
|
||||
/// Structure size
|
||||
/// </summary>
|
||||
public uint CbSize;
|
||||
|
||||
/// <summary>
|
||||
/// Monitor rectangle area
|
||||
/// </summary>
|
||||
public Rect RcMonitor;
|
||||
|
||||
/// <summary>
|
||||
/// Work area rectangle
|
||||
/// </summary>
|
||||
public Rect RcWork;
|
||||
|
||||
/// <summary>
|
||||
/// Flags
|
||||
/// </summary>
|
||||
public uint DwFlags;
|
||||
|
||||
/// <summary>
|
||||
/// Device name (e.g., "\\.\DISPLAY1") - fixed buffer for LibraryImport compatibility
|
||||
/// </summary>
|
||||
public fixed ushort SzDevice[32];
|
||||
|
||||
/// <summary>
|
||||
/// Helper property to get device name as string
|
||||
/// </summary>
|
||||
public readonly string GetDeviceName()
|
||||
{
|
||||
fixed (ushort* ptr = SzDevice)
|
||||
{
|
||||
return new string((char*)ptr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Display device structure
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||
public unsafe struct DisplayDevice
|
||||
{
|
||||
/// <summary>
|
||||
/// Structure size
|
||||
/// </summary>
|
||||
public uint Cb;
|
||||
|
||||
/// <summary>
|
||||
/// Device name (e.g., "\\.\DISPLAY1\Monitor0") - fixed buffer for LibraryImport compatibility
|
||||
/// </summary>
|
||||
public fixed ushort DeviceName[32];
|
||||
|
||||
/// <summary>
|
||||
/// Device description string - fixed buffer for LibraryImport compatibility
|
||||
/// </summary>
|
||||
public fixed ushort DeviceString[128];
|
||||
|
||||
/// <summary>
|
||||
/// Status flags
|
||||
/// </summary>
|
||||
public uint StateFlags;
|
||||
|
||||
/// <summary>
|
||||
/// Device ID - fixed buffer for LibraryImport compatibility
|
||||
/// </summary>
|
||||
public fixed ushort DeviceID[128];
|
||||
|
||||
/// <summary>
|
||||
/// Registry device key - fixed buffer for LibraryImport compatibility
|
||||
/// </summary>
|
||||
public fixed ushort DeviceKey[128];
|
||||
|
||||
/// <summary>
|
||||
/// Helper method to get device name as string
|
||||
/// </summary>
|
||||
public readonly string GetDeviceName()
|
||||
{
|
||||
fixed (ushort* ptr = DeviceName)
|
||||
{
|
||||
return new string((char*)ptr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// LUID (Locally Unique Identifier) structure
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct Luid
|
||||
{
|
||||
public uint LowPart;
|
||||
public int HighPart;
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return $"{HighPart:X8}:{LowPart:X8}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Display configuration path information
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct DISPLAYCONFIG_PATH_INFO
|
||||
{
|
||||
public DISPLAYCONFIG_PATH_SOURCE_INFO SourceInfo;
|
||||
public DISPLAYCONFIG_PATH_TARGET_INFO TargetInfo;
|
||||
public uint Flags;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Display configuration path source information
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct DISPLAYCONFIG_PATH_SOURCE_INFO
|
||||
{
|
||||
public Luid AdapterId;
|
||||
public uint Id;
|
||||
public uint ModeInfoIdx;
|
||||
public uint StatusFlags;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Display configuration path target information
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct DISPLAYCONFIG_PATH_TARGET_INFO
|
||||
{
|
||||
public Luid AdapterId;
|
||||
public uint Id;
|
||||
public uint ModeInfoIdx;
|
||||
public uint OutputTechnology;
|
||||
public uint Rotation;
|
||||
public uint Scaling;
|
||||
public DISPLAYCONFIG_RATIONAL RefreshRate;
|
||||
public uint ScanLineOrdering;
|
||||
public bool TargetAvailable;
|
||||
public uint StatusFlags;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Display configuration rational number
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct DISPLAYCONFIG_RATIONAL
|
||||
{
|
||||
public uint Numerator;
|
||||
public uint Denominator;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Display configuration mode information
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct DISPLAYCONFIG_MODE_INFO
|
||||
{
|
||||
public uint InfoType;
|
||||
public uint Id;
|
||||
public Luid AdapterId;
|
||||
public DISPLAYCONFIG_MODE_INFO_UNION ModeInfo;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Display configuration mode information union
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Explicit)]
|
||||
public struct DISPLAYCONFIG_MODE_INFO_UNION
|
||||
{
|
||||
[FieldOffset(0)]
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1307:Accessible fields should begin with upper-case letter", Justification = "Native API structure field")]
|
||||
public DISPLAYCONFIG_TARGET_MODE targetMode;
|
||||
|
||||
[FieldOffset(0)]
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1307:Accessible fields should begin with upper-case letter", Justification = "Native API structure field")]
|
||||
public DISPLAYCONFIG_SOURCE_MODE sourceMode;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Display configuration target mode
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct DISPLAYCONFIG_TARGET_MODE
|
||||
{
|
||||
public DISPLAYCONFIG_VIDEO_SIGNAL_INFO TargetVideoSignalInfo;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Display configuration source mode
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct DISPLAYCONFIG_SOURCE_MODE
|
||||
{
|
||||
public uint Width;
|
||||
public uint Height;
|
||||
public uint PixelFormat;
|
||||
public DISPLAYCONFIG_POINT Position;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Display configuration point
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct DISPLAYCONFIG_POINT
|
||||
{
|
||||
public int X;
|
||||
public int Y;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Display configuration video signal information
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct DISPLAYCONFIG_VIDEO_SIGNAL_INFO
|
||||
{
|
||||
public ulong PixelRate;
|
||||
public DISPLAYCONFIG_RATIONAL HSyncFreq;
|
||||
public DISPLAYCONFIG_RATIONAL VSyncFreq;
|
||||
public DISPLAYCONFIG_2DREGION ActiveSize;
|
||||
public DISPLAYCONFIG_2DREGION TotalSize;
|
||||
public uint VideoStandard;
|
||||
public uint ScanLineOrdering;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Display configuration 2D region
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct DISPLAYCONFIG_2DREGION
|
||||
{
|
||||
public uint Cx;
|
||||
public uint Cy;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Display configuration device information header
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct DISPLAYCONFIG_DEVICE_INFO_HEADER
|
||||
{
|
||||
public uint Type;
|
||||
public uint Size;
|
||||
public Luid AdapterId;
|
||||
public uint Id;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Display configuration source device name - contains GDI device name (e.g., "\\.\DISPLAY1")
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||
public unsafe struct DISPLAYCONFIG_SOURCE_DEVICE_NAME
|
||||
{
|
||||
public DISPLAYCONFIG_DEVICE_INFO_HEADER Header;
|
||||
|
||||
/// <summary>
|
||||
/// GDI device name - fixed buffer for 32 wide characters (CCHDEVICENAME)
|
||||
/// </summary>
|
||||
public fixed ushort ViewGdiDeviceName[32];
|
||||
|
||||
/// <summary>
|
||||
/// Helper method to get GDI device name as string
|
||||
/// </summary>
|
||||
public readonly string GetViewGdiDeviceName()
|
||||
{
|
||||
fixed (ushort* ptr = ViewGdiDeviceName)
|
||||
{
|
||||
return new string((char*)ptr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Display configuration target device name
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||
public unsafe struct DISPLAYCONFIG_TARGET_DEVICE_NAME
|
||||
{
|
||||
public DISPLAYCONFIG_DEVICE_INFO_HEADER Header;
|
||||
public uint Flags;
|
||||
public uint OutputTechnology;
|
||||
public ushort EdidManufactureId;
|
||||
public ushort EdidProductCodeId;
|
||||
public uint ConnectorInstance;
|
||||
|
||||
/// <summary>
|
||||
/// Monitor friendly name - fixed buffer for LibraryImport compatibility
|
||||
/// </summary>
|
||||
public fixed ushort MonitorFriendlyDeviceName[64];
|
||||
|
||||
/// <summary>
|
||||
/// Monitor device path - fixed buffer for LibraryImport compatibility
|
||||
/// </summary>
|
||||
public fixed ushort MonitorDevicePath[128];
|
||||
|
||||
/// <summary>
|
||||
/// Helper method to get monitor friendly name as string
|
||||
/// </summary>
|
||||
public readonly string GetMonitorFriendlyDeviceName()
|
||||
{
|
||||
fixed (ushort* ptr = MonitorFriendlyDeviceName)
|
||||
{
|
||||
return new string((char*)ptr);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper method to get monitor device path as string
|
||||
/// </summary>
|
||||
public readonly string GetMonitorDevicePath()
|
||||
{
|
||||
fixed (ushort* ptr = MonitorDevicePath)
|
||||
{
|
||||
return new string((char*)ptr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Display configuration SDR white level
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct DISPLAYCONFIG_SDR_WHITE_LEVEL
|
||||
{
|
||||
public DISPLAYCONFIG_DEVICE_INFO_HEADER Header;
|
||||
public uint SDRWhiteLevel;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Display configuration advanced color information
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct DISPLAYCONFIG_GET_ADVANCED_COLOR_INFO
|
||||
{
|
||||
public DISPLAYCONFIG_DEVICE_INFO_HEADER Header;
|
||||
public uint AdvancedColorSupported;
|
||||
public uint AdvancedColorEnabled;
|
||||
public uint BitsPerColorChannel;
|
||||
public uint ColorEncoding;
|
||||
public uint FormatSupport;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Custom structure for setting SDR white level
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct DISPLAYCONFIG_SET_SDR_WHITE_LEVEL
|
||||
{
|
||||
public DISPLAYCONFIG_DEVICE_INFO_HEADER Header;
|
||||
public uint SDRWhiteLevel;
|
||||
public byte FinalValue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Point structure
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct POINT
|
||||
{
|
||||
public int X;
|
||||
public int Y;
|
||||
|
||||
public POINT(int x, int y)
|
||||
{
|
||||
this.X = x;
|
||||
this.Y = y;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The DEVMODE structure contains information about the initialization and environment of a printer or a display device.
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||
public unsafe struct DevMode
|
||||
{
|
||||
/// <summary>
|
||||
/// Device name - fixed buffer for LibraryImport compatibility
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Form name - fixed buffer for LibraryImport compatibility
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
151
src/modules/powerdisplay/PowerDisplay.Lib/Drivers/PInvoke.cs
Normal file
151
src/modules/powerdisplay/PowerDisplay.Lib/Drivers/PInvoke.cs
Normal file
@@ -0,0 +1,151 @@
|
||||
// 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
|
||||
{
|
||||
/// <summary>
|
||||
/// P/Invoke declarations using LibraryImport source generator
|
||||
/// </summary>
|
||||
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,
|
||||
DISPLAYCONFIG_PATH_INFO* pathArray,
|
||||
ref uint numModeInfoArrayElements,
|
||||
DISPLAYCONFIG_MODE_INFO* modeInfoArray,
|
||||
IntPtr currentTopologyId);
|
||||
|
||||
[LibraryImport("user32.dll")]
|
||||
internal static partial int DisplayConfigGetDeviceInfo(
|
||||
ref DISPLAYCONFIG_TARGET_DEVICE_NAME deviceName);
|
||||
|
||||
[LibraryImport("user32.dll")]
|
||||
internal static partial int DisplayConfigGetDeviceInfo(
|
||||
ref DISPLAYCONFIG_SOURCE_DEVICE_NAME 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 partial bool GetMonitorInfo(
|
||||
IntPtr hMonitor,
|
||||
ref MonitorInfoEx lpmi);
|
||||
|
||||
[LibraryImport("user32.dll", EntryPoint = "EnumDisplayDevicesW", StringMarshalling = StringMarshalling.Utf16)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
internal static partial bool EnumDisplayDevices(
|
||||
[MarshalAs(UnmanagedType.LPWStr)] string? lpDevice,
|
||||
uint iDevNum,
|
||||
ref DisplayDevice lpDisplayDevice,
|
||||
uint dwFlags);
|
||||
|
||||
[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);
|
||||
|
||||
[LibraryImport("user32.dll")]
|
||||
internal static partial IntPtr MonitorFromWindow(
|
||||
IntPtr hwnd,
|
||||
uint dwFlags);
|
||||
|
||||
[LibraryImport("user32.dll")]
|
||||
internal static partial IntPtr MonitorFromPoint(
|
||||
POINT pt,
|
||||
uint dwFlags);
|
||||
|
||||
// ==================== 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);
|
||||
|
||||
// Use unsafe pointer to avoid LPArray limitation
|
||||
[LibraryImport("Dxva2.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
internal static unsafe partial bool DestroyPhysicalMonitors(
|
||||
uint dwPhysicalMonitorArraySize,
|
||||
PhysicalMonitor* pPhysicalMonitorArray);
|
||||
|
||||
[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 SaveCurrentSettings(IntPtr hPhysicalMonitor);
|
||||
|
||||
[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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
// 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
|
||||
{
|
||||
/// <summary>
|
||||
/// WMI monitor controller for controlling internal laptop displays.
|
||||
/// Rewritten to use WmiLight library for Native AOT compatibility.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Classifies WMI exceptions into user-friendly error messages.
|
||||
/// </summary>
|
||||
private static MonitorOperationResult ClassifyWmiError(WmiException ex, string operation)
|
||||
{
|
||||
var hresult = ex.HResult;
|
||||
|
||||
return hresult switch
|
||||
{
|
||||
WbemENotFound => MonitorOperationResult.Failure($"WMI class not found during {operation}. This feature may not be supported on your system.", hresult),
|
||||
WbemEAccessDenied => MonitorOperationResult.Failure($"Access denied during {operation}. Administrator privileges may be required.", hresult),
|
||||
WbemEProviderFailure => MonitorOperationResult.Failure($"WMI provider failure during {operation}. The display driver may not support this feature.", hresult),
|
||||
WbemEInvalidQuery => MonitorOperationResult.Failure($"Invalid WMI query during {operation}. This is likely a bug.", hresult),
|
||||
WmiFeatureNotSupported => MonitorOperationResult.Failure($"WMI brightness control not supported on this system during {operation}.", hresult),
|
||||
_ => MonitorOperationResult.Failure($"WMI error during {operation}: {ex.Message}", hresult),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// </summary>
|
||||
/// <param name="value">The string value to escape.</param>
|
||||
/// <returns>The escaped string safe for use in WMI queries.</returns>
|
||||
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("'", "\\'");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="instanceName">The WMI InstanceName.</param>
|
||||
/// <returns>The hardware ID extracted from the InstanceName, or empty string if extraction fails.</returns>
|
||||
private static string ExtractHardwareIdFromInstanceName(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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build a WMI query filtered by monitor instance name.
|
||||
/// </summary>
|
||||
/// <param name="wmiClass">The WMI class to query.</param>
|
||||
/// <param name="instanceName">The monitor instance name to filter by.</param>
|
||||
/// <param name="selectClause">Optional SELECT clause fields (defaults to "*").</param>
|
||||
/// <returns>The formatted WMI query string.</returns>
|
||||
private static string BuildInstanceNameQuery(string wmiClass, string instanceName, string selectClause = "*")
|
||||
{
|
||||
var escapedInstanceName = EscapeWmiString(instanceName);
|
||||
return $"SELECT {selectClause} FROM {wmiClass} WHERE InstanceName = '{escapedInstanceName}'";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get MonitorDisplayInfo from dictionary by matching HardwareId.
|
||||
/// Uses QueryDisplayConfig path index which matches Windows Display Settings "Identify" feature.
|
||||
/// </summary>
|
||||
/// <param name="hardwareId">The hardware ID to match (e.g., "LEN4038", "BOE0900").</param>
|
||||
/// <param name="monitorDisplayInfos">Dictionary of monitor display info from QueryDisplayConfig.</param>
|
||||
/// <returns>MonitorDisplayInfo if found, or null if not found.</returns>
|
||||
private static Drivers.DDC.MonitorDisplayInfo? GetMonitorDisplayInfoByHardwareId(string hardwareId, Dictionary<string, Drivers.DDC.MonitorDisplayInfo> monitorDisplayInfos)
|
||||
{
|
||||
if (string.IsNullOrEmpty(hardwareId) || monitorDisplayInfos == null || monitorDisplayInfos.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var match = monitorDisplayInfos.Values.FirstOrDefault(
|
||||
v => hardwareId.Equals(v.HardwareId, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
// Check if match was found (struct default has null/empty HardwareId)
|
||||
if (!string.IsNullOrEmpty(match.HardwareId))
|
||||
{
|
||||
return match;
|
||||
}
|
||||
|
||||
Logger.LogWarning($"WMI: Could not find MonitorDisplayInfo for HardwareId '{hardwareId}'");
|
||||
return null;
|
||||
}
|
||||
|
||||
public string Name => "WMI Monitor Controller";
|
||||
|
||||
/// <summary>
|
||||
/// Get monitor brightness
|
||||
/// </summary>
|
||||
public async Task<VcpFeatureValue> 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<byte>("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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set monitor brightness
|
||||
/// </summary>
|
||||
public async Task<MonitorOperationResult> SetBrightnessAsync(Monitor monitor, int brightness, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(monitor);
|
||||
|
||||
// Validate brightness range
|
||||
brightness = Math.Clamp(brightness, 0, 100);
|
||||
|
||||
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<uint>(
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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".
|
||||
/// </summary>
|
||||
public async Task<IEnumerable<Monitor>> DiscoverMonitorsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await Task.Run(
|
||||
() =>
|
||||
{
|
||||
var monitors = new List<Monitor>();
|
||||
|
||||
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<string>("InstanceName") ?? string.Empty;
|
||||
var currentBrightness = obj.GetPropertyValue<byte>("CurrentBrightness");
|
||||
|
||||
// Extract hardware ID from InstanceName
|
||||
// e.g., "DISPLAY\LEN4038\4&40f4dee&0&UID8388688_0" -> "LEN4038"
|
||||
var hardwareId = ExtractHardwareIdFromInstanceName(instanceName);
|
||||
|
||||
// Get MonitorDisplayInfo from QueryDisplayConfig by matching hardware ID
|
||||
// This provides MonitorNumber and GdiDeviceName for display settings APIs
|
||||
var displayInfo = GetMonitorDisplayInfoByHardwareId(hardwareId, monitorDisplayInfos);
|
||||
int monitorNumber = displayInfo?.MonitorNumber ?? 0;
|
||||
string gdiDeviceName = displayInfo?.GdiDeviceName ?? string.Empty;
|
||||
|
||||
// Generate unique monitor Id: "WMI_{HardwareId}_{MonitorNumber}"
|
||||
string monitorId = !string.IsNullOrEmpty(hardwareId)
|
||||
? $"WMI_{hardwareId}_{monitorNumber}"
|
||||
: $"WMI_Unknown_{monitorNumber}";
|
||||
|
||||
// Get display name from PnP manufacturer ID (e.g., "Lenovo Built-in Display")
|
||||
var displayName = PnpIdHelper.GetBuiltInDisplayName(hardwareId);
|
||||
|
||||
var monitor = new Monitor
|
||||
{
|
||||
Id = monitorId,
|
||||
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<MonitorOperationResult> SetContrastAsync(Monitor monitor, int contrast, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(MonitorOperationResult.Failure("Contrast control not supported via WMI"));
|
||||
}
|
||||
|
||||
public Task<MonitorOperationResult> SetVolumeAsync(Monitor monitor, int volume, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(MonitorOperationResult.Failure("Volume control not supported via WMI"));
|
||||
}
|
||||
|
||||
public Task<VcpFeatureValue> GetColorTemperatureAsync(Monitor monitor, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(VcpFeatureValue.Invalid);
|
||||
}
|
||||
|
||||
public Task<MonitorOperationResult> SetColorTemperatureAsync(Monitor monitor, int colorTemperature, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(MonitorOperationResult.Failure("Color temperature control not supported via WMI"));
|
||||
}
|
||||
|
||||
public Task<VcpFeatureValue> GetInputSourceAsync(Monitor monitor, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Input source switching not supported for internal displays
|
||||
return Task.FromResult(VcpFeatureValue.Invalid);
|
||||
}
|
||||
|
||||
public Task<MonitorOperationResult> 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 void Dispose()
|
||||
{
|
||||
// WmiLight objects are created per-operation and disposed immediately via using statements.
|
||||
// No instance-level resources require cleanup.
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// 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
|
||||
{
|
||||
/// <summary>
|
||||
/// Monitor controller interface
|
||||
/// </summary>
|
||||
public interface IMonitorController
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets controller name
|
||||
/// </summary>
|
||||
string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets monitor brightness
|
||||
/// </summary>
|
||||
/// <param name="monitor">Monitor object</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Brightness information</returns>
|
||||
Task<VcpFeatureValue> GetBrightnessAsync(Monitor monitor, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Sets monitor brightness
|
||||
/// </summary>
|
||||
/// <param name="monitor">Monitor object</param>
|
||||
/// <param name="brightness">Brightness value (0-100)</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Operation result</returns>
|
||||
Task<MonitorOperationResult> SetBrightnessAsync(Monitor monitor, int brightness, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Discovers supported monitors
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>List of monitors</returns>
|
||||
Task<IEnumerable<Monitor>> DiscoverMonitorsAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Sets monitor contrast
|
||||
/// </summary>
|
||||
/// <param name="monitor">Monitor object</param>
|
||||
/// <param name="contrast">Contrast value (0-100)</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Operation result</returns>
|
||||
Task<MonitorOperationResult> SetContrastAsync(Monitor monitor, int contrast, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Sets monitor volume
|
||||
/// </summary>
|
||||
/// <param name="monitor">Monitor object</param>
|
||||
/// <param name="volume">Volume value (0-100)</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Operation result</returns>
|
||||
Task<MonitorOperationResult> SetVolumeAsync(Monitor monitor, int volume, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets monitor color temperature using VCP 0x14 (Select Color Preset)
|
||||
/// </summary>
|
||||
/// <param name="monitor">Monitor object</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>VCP preset value (e.g., 0x05 for 6500K), not Kelvin temperature</returns>
|
||||
Task<VcpFeatureValue> GetColorTemperatureAsync(Monitor monitor, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Sets monitor color temperature using VCP 0x14 preset value
|
||||
/// </summary>
|
||||
/// <param name="monitor">Monitor object</param>
|
||||
/// <param name="colorTemperature">VCP preset value (e.g., 0x05 for 6500K), not Kelvin temperature</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Operation result</returns>
|
||||
Task<MonitorOperationResult> SetColorTemperatureAsync(Monitor monitor, int colorTemperature, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets current input source using VCP 0x60
|
||||
/// </summary>
|
||||
/// <param name="monitor">Monitor object</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>VCP input source value (e.g., 0x11 for HDMI-1)</returns>
|
||||
Task<VcpFeatureValue> GetInputSourceAsync(Monitor monitor, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Sets input source using VCP 0x60
|
||||
/// </summary>
|
||||
/// <param name="monitor">Monitor object</param>
|
||||
/// <param name="inputSource">VCP input source value (e.g., 0x11 for HDMI-1)</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Operation result</returns>
|
||||
Task<MonitorOperationResult> SetInputSourceAsync(Monitor monitor, int inputSource, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Releases resources
|
||||
/// </summary>
|
||||
void Dispose();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public interface IMonitorData
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the unique identifier for the monitor.
|
||||
/// </summary>
|
||||
string Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the display name of the monitor.
|
||||
/// </summary>
|
||||
string Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the current brightness value (0-100).
|
||||
/// </summary>
|
||||
int Brightness { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the current contrast value (0-100).
|
||||
/// </summary>
|
||||
int Contrast { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the current volume value (0-100).
|
||||
/// </summary>
|
||||
int Volume { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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 MonitorValueConverter to convert to/from human-readable Kelvin values.
|
||||
/// </summary>
|
||||
int ColorTemperatureVcp { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the monitor number (1, 2, 3...) as assigned by the OS.
|
||||
/// </summary>
|
||||
int MonitorNumber { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the monitor orientation (0=0, 1=90, 2=180, 3=270).
|
||||
/// </summary>
|
||||
int Orientation { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Interface for profile management service.
|
||||
/// Provides abstraction for loading, saving, and managing PowerDisplay profiles.
|
||||
/// Enables dependency injection and unit testing.
|
||||
/// </summary>
|
||||
public interface IProfileService
|
||||
{
|
||||
/// <summary>
|
||||
/// Loads PowerDisplay profiles from disk.
|
||||
/// </summary>
|
||||
/// <returns>PowerDisplayProfiles object, or a new empty instance if file doesn't exist or load fails.</returns>
|
||||
PowerDisplayProfiles LoadProfiles();
|
||||
|
||||
/// <summary>
|
||||
/// Saves PowerDisplay profiles to disk.
|
||||
/// </summary>
|
||||
/// <param name="profiles">The profiles collection to save.</param>
|
||||
/// <returns>True if save was successful, false otherwise.</returns>
|
||||
bool SaveProfiles(PowerDisplayProfiles profiles);
|
||||
|
||||
/// <summary>
|
||||
/// Adds or updates a profile in the collection and persists to disk.
|
||||
/// </summary>
|
||||
/// <param name="profile">The profile to add or update.</param>
|
||||
/// <returns>True if operation was successful, false otherwise.</returns>
|
||||
bool AddOrUpdateProfile(PowerDisplayProfile profile);
|
||||
|
||||
/// <summary>
|
||||
/// Removes a profile by name and persists to disk.
|
||||
/// </summary>
|
||||
/// <param name="profileName">The name of the profile to remove.</param>
|
||||
/// <returns>True if profile was found and removed, false otherwise.</returns>
|
||||
bool RemoveProfile(string profileName);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a profile by name.
|
||||
/// </summary>
|
||||
/// <param name="profileName">The name of the profile to retrieve.</param>
|
||||
/// <returns>The profile if found, null otherwise.</returns>
|
||||
PowerDisplayProfile? GetProfile(string profileName);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the profiles file exists.
|
||||
/// </summary>
|
||||
/// <returns>True if profiles file exists, false otherwise.</returns>
|
||||
bool ProfilesFileExists();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the path to the profiles file.
|
||||
/// </summary>
|
||||
/// <returns>The full path to the profiles file.</returns>
|
||||
string GetProfilesFilePath();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents a color temperature preset item for VCP code 0x14.
|
||||
/// Used to display available color temperature presets in UI components.
|
||||
/// </summary>
|
||||
public partial class ColorPresetItem : INotifyPropertyChanged
|
||||
{
|
||||
private int _vcpValue;
|
||||
private string _displayName = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ColorPresetItem"/> class.
|
||||
/// </summary>
|
||||
public ColorPresetItem()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ColorPresetItem"/> class.
|
||||
/// </summary>
|
||||
/// <param name="vcpValue">The VCP value for the color temperature preset.</param>
|
||||
/// <param name="displayName">The display name for UI.</param>
|
||||
public ColorPresetItem(int vcpValue, string displayName)
|
||||
{
|
||||
_vcpValue = vcpValue;
|
||||
_displayName = displayName;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Occurs when a property value changes.
|
||||
/// </summary>
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the VCP value for this color temperature preset.
|
||||
/// </summary>
|
||||
[JsonPropertyName("vcpValue")]
|
||||
public int VcpValue
|
||||
{
|
||||
get => _vcpValue;
|
||||
set
|
||||
{
|
||||
if (_vcpValue != value)
|
||||
{
|
||||
_vcpValue = value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the display name for UI.
|
||||
/// </summary>
|
||||
[JsonPropertyName("displayName")]
|
||||
public string DisplayName
|
||||
{
|
||||
get => _displayName;
|
||||
set
|
||||
{
|
||||
if (_displayName != value)
|
||||
{
|
||||
_displayName = value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raises the PropertyChanged event.
|
||||
/// </summary>
|
||||
/// <param name="propertyName">The name of the property that changed.</param>
|
||||
protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null)
|
||||
{
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 PowerDisplay.Common.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents a pending color temperature change operation
|
||||
/// </summary>
|
||||
public class ColorTemperatureOperation
|
||||
{
|
||||
[JsonPropertyName("monitor_id")]
|
||||
public string MonitorId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the color temperature VCP preset value.
|
||||
/// JSON property name kept as "color_temperature" for IPC compatibility.
|
||||
/// </summary>
|
||||
[JsonPropertyName("color_temperature")]
|
||||
public int ColorTemperatureVcp { get; set; }
|
||||
|
||||
public ColorTemperatureOperation()
|
||||
{
|
||||
MonitorId = string.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
352
src/modules/powerdisplay/PowerDisplay.Lib/Models/Monitor.cs
Normal file
352
src/modules/powerdisplay/PowerDisplay.Lib/Models/Monitor.cs
Normal file
@@ -0,0 +1,352 @@
|
||||
// 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
|
||||
{
|
||||
/// <summary>
|
||||
/// Monitor model that implements property change notification.
|
||||
/// Implements IMonitorData to provide a common interface for monitor hardware values.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><see cref="Id"/> is the unique identifier used for all purposes: UI lookups, IPC, persistent storage, and handle management.</para>
|
||||
/// <para>Format: "{Source}_{EdidId}_{MonitorNumber}" (e.g., "DDC_GSM5C6D_1", "WMI_BOE0900_2").</para>
|
||||
/// </remarks>
|
||||
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 bool _isAvailable = true;
|
||||
private int _orientation;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets unique identifier for all purposes: UI lookups, IPC, persistent storage, and handle management.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets display name
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets current brightness (0-100)
|
||||
/// </summary>
|
||||
public int CurrentBrightness
|
||||
{
|
||||
get => _currentBrightness;
|
||||
set
|
||||
{
|
||||
var clamped = Math.Clamp(value, MinBrightness, MaxBrightness);
|
||||
if (_currentBrightness != clamped)
|
||||
{
|
||||
_currentBrightness = clamped;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets minimum brightness value
|
||||
/// </summary>
|
||||
public int MinBrightness { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets maximum brightness value
|
||||
/// </summary>
|
||||
public int MaxBrightness { get; set; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public int CurrentColorTemperature
|
||||
{
|
||||
get => _currentColorTemperature;
|
||||
set
|
||||
{
|
||||
if (_currentColorTemperature != value)
|
||||
{
|
||||
_currentColorTemperature = value;
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(ColorTemperaturePresetName));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets human-readable color temperature preset name (e.g., "6500K (0x05)", "sRGB (0x01)")
|
||||
/// </summary>
|
||||
public string ColorTemperaturePresetName =>
|
||||
VcpNames.GetFormattedValueName(0x14, CurrentColorTemperature);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the monitor supports color temperature adjustment via VCP 0x14
|
||||
/// </summary>
|
||||
public bool SupportsColorTemperature { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public int CurrentInputSource
|
||||
{
|
||||
get => _currentInputSource;
|
||||
set
|
||||
{
|
||||
if (_currentInputSource != value)
|
||||
{
|
||||
_currentInputSource = value;
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(InputSourceName));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets human-readable input source name (e.g., "HDMI-1", "DisplayPort-1")
|
||||
/// Returns just the name without hex value for cleaner UI display.
|
||||
/// </summary>
|
||||
public string InputSourceName =>
|
||||
VcpNames.GetValueName(0x60, CurrentInputSource) ?? $"Source 0x{CurrentInputSource:X2}";
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the monitor supports input source switching via VCP 0x60
|
||||
/// </summary>
|
||||
public bool SupportsInputSource => VcpCapabilitiesInfo?.SupportsVcpCode(0x60) ?? false;
|
||||
|
||||
/// <summary>
|
||||
/// Gets get supported input sources from capabilities (as list of VCP values)
|
||||
/// </summary>
|
||||
public System.Collections.Generic.IReadOnlyList<int>? SupportedInputSources =>
|
||||
VcpCapabilitiesInfo?.GetSupportedValues(0x60);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the monitor supports contrast adjustment
|
||||
/// </summary>
|
||||
public bool SupportsContrast => Capabilities.HasFlag(MonitorCapabilities.Contrast);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the monitor supports volume adjustment (for audio-capable monitors)
|
||||
/// </summary>
|
||||
public bool SupportsVolume => Capabilities.HasFlag(MonitorCapabilities.Volume);
|
||||
|
||||
private int _currentContrast = 50;
|
||||
private int _currentVolume = 50;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets current contrast (0-100)
|
||||
/// </summary>
|
||||
public int CurrentContrast
|
||||
{
|
||||
get => _currentContrast;
|
||||
set
|
||||
{
|
||||
var clamped = Math.Clamp(value, MinContrast, MaxContrast);
|
||||
if (_currentContrast != clamped)
|
||||
{
|
||||
_currentContrast = clamped;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets minimum contrast value
|
||||
/// </summary>
|
||||
public int MinContrast { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets maximum contrast value
|
||||
/// </summary>
|
||||
public int MaxContrast { get; set; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets current volume (0-100)
|
||||
/// </summary>
|
||||
public int CurrentVolume
|
||||
{
|
||||
get => _currentVolume;
|
||||
set
|
||||
{
|
||||
var clamped = Math.Clamp(value, MinVolume, MaxVolume);
|
||||
if (_currentVolume != clamped)
|
||||
{
|
||||
_currentVolume = clamped;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets minimum volume value
|
||||
/// </summary>
|
||||
public int MinVolume { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets maximum volume value
|
||||
/// </summary>
|
||||
public int MaxVolume { get; set; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the monitor is available/online
|
||||
/// </summary>
|
||||
public bool IsAvailable
|
||||
{
|
||||
get => _isAvailable;
|
||||
set
|
||||
{
|
||||
if (_isAvailable != value)
|
||||
{
|
||||
_isAvailable = value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets physical monitor handle (for DDC/CI)
|
||||
/// </summary>
|
||||
public IntPtr Handle { get; set; } = IntPtr.Zero;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets instance name (used by WMI)
|
||||
/// </summary>
|
||||
public string InstanceName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets communication method (DDC/CI, WMI, HDR API, etc.)
|
||||
/// </summary>
|
||||
public string CommunicationMethod { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets supported control methods
|
||||
/// </summary>
|
||||
public MonitorCapabilities Capabilities { get; set; } = MonitorCapabilities.None;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets raw DDC/CI capabilities string (MCCS format)
|
||||
/// </summary>
|
||||
public string? CapabilitiesRaw { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets parsed VCP capabilities information
|
||||
/// </summary>
|
||||
public VcpCapabilities? VcpCapabilitiesInfo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets last update time
|
||||
/// </summary>
|
||||
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}%";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update monitor status
|
||||
/// </summary>
|
||||
public void UpdateStatus(int brightness, bool isAvailable = true)
|
||||
{
|
||||
IsAvailable = isAvailable;
|
||||
if (isAvailable)
|
||||
{
|
||||
CurrentBrightness = brightness;
|
||||
LastUpdate = DateTime.Now;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
int IMonitorData.Brightness
|
||||
{
|
||||
get => CurrentBrightness;
|
||||
set => CurrentBrightness = value;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
int IMonitorData.Contrast
|
||||
{
|
||||
get => CurrentContrast;
|
||||
set => CurrentContrast = value;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
int IMonitorData.Volume
|
||||
{
|
||||
get => CurrentVolume;
|
||||
set => CurrentVolume = value;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
int IMonitorData.ColorTemperatureVcp
|
||||
{
|
||||
get => CurrentColorTemperature;
|
||||
set => CurrentColorTemperature = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets monitor number (1, 2, 3...)
|
||||
/// </summary>
|
||||
public int MonitorNumber { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
public string GdiDeviceName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets monitor orientation (0=0, 1=90, 2=180, 3=270).
|
||||
/// Fires PropertyChanged when value changes.
|
||||
/// </summary>
|
||||
public int Orientation
|
||||
{
|
||||
get => _orientation;
|
||||
set
|
||||
{
|
||||
if (_orientation != value)
|
||||
{
|
||||
_orientation = value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
int IMonitorData.MonitorNumber
|
||||
{
|
||||
get => MonitorNumber;
|
||||
set => MonitorNumber = value;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
int IMonitorData.Orientation
|
||||
{
|
||||
get => Orientation;
|
||||
set => Orientation = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Monitor control capabilities flags
|
||||
/// </summary>
|
||||
[Flags]
|
||||
public enum MonitorCapabilities
|
||||
{
|
||||
None = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Supports brightness control
|
||||
/// </summary>
|
||||
Brightness = 1 << 0,
|
||||
|
||||
/// <summary>
|
||||
/// Supports contrast control
|
||||
/// </summary>
|
||||
Contrast = 1 << 1,
|
||||
|
||||
/// <summary>
|
||||
/// Supports DDC/CI protocol
|
||||
/// </summary>
|
||||
DdcCi = 1 << 2,
|
||||
|
||||
/// <summary>
|
||||
/// Supports WMI control
|
||||
/// </summary>
|
||||
Wmi = 1 << 3,
|
||||
|
||||
/// <summary>
|
||||
/// Supports HDR
|
||||
/// </summary>
|
||||
Hdr = 1 << 4,
|
||||
|
||||
/// <summary>
|
||||
/// Supports high-level monitor API
|
||||
/// </summary>
|
||||
HighLevel = 1 << 5,
|
||||
|
||||
/// <summary>
|
||||
/// Supports volume control
|
||||
/// </summary>
|
||||
Volume = 1 << 6,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Monitor operation result
|
||||
/// </summary>
|
||||
public readonly struct MonitorOperationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the operation was successful
|
||||
/// </summary>
|
||||
public bool IsSuccess { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets error message
|
||||
/// </summary>
|
||||
public string? ErrorMessage { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets system error code
|
||||
/// </summary>
|
||||
public int? ErrorCode { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets operation timestamp
|
||||
/// </summary>
|
||||
public DateTime Timestamp { get; }
|
||||
|
||||
private MonitorOperationResult(bool isSuccess, string? errorMessage = null, int? errorCode = null)
|
||||
{
|
||||
IsSuccess = isSuccess;
|
||||
ErrorMessage = errorMessage;
|
||||
ErrorCode = errorCode;
|
||||
Timestamp = DateTime.Now;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a successful result
|
||||
/// </summary>
|
||||
public static MonitorOperationResult Success() => new(true);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a failed result
|
||||
/// </summary>
|
||||
public static MonitorOperationResult Failure(string errorMessage, int? errorCode = null)
|
||||
=> new(false, errorMessage, errorCode);
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return IsSuccess ? "Success" : $"Failed: {ErrorMessage}";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Individual monitor state entry for JSON persistence.
|
||||
/// Stores the current state of a monitor's adjustable parameters.
|
||||
/// </summary>
|
||||
public sealed class MonitorStateEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the brightness level (0-100).
|
||||
/// </summary>
|
||||
[JsonPropertyName("brightness")]
|
||||
public int Brightness { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the color temperature VCP value.
|
||||
/// </summary>
|
||||
[JsonPropertyName("colorTemperature")]
|
||||
public int ColorTemperatureVcp { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the contrast level (0-100).
|
||||
/// </summary>
|
||||
[JsonPropertyName("contrast")]
|
||||
public int Contrast { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the volume level (0-100).
|
||||
/// </summary>
|
||||
[JsonPropertyName("volume")]
|
||||
public int Volume { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the raw capabilities string from DDC/CI.
|
||||
/// </summary>
|
||||
[JsonPropertyName("capabilitiesRaw")]
|
||||
public string? CapabilitiesRaw { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets when this entry was last updated.
|
||||
/// </summary>
|
||||
[JsonPropertyName("lastUpdated")]
|
||||
public DateTime LastUpdated { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Monitor state file structure for JSON persistence.
|
||||
/// Contains all monitor states indexed by Monitor.Id.
|
||||
/// </summary>
|
||||
public sealed class MonitorStateFile
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the monitor states dictionary.
|
||||
/// Key is the monitor's unique Id (e.g., "DDC_GSM5C6D_1", "WMI_BOE0900_2").
|
||||
/// </summary>
|
||||
[JsonPropertyName("monitors")]
|
||||
public Dictionary<string, MonitorStateEntry> Monitors { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets when the file was last updated.
|
||||
/// </summary>
|
||||
[JsonPropertyName("lastUpdated")]
|
||||
public DateTime LastUpdated { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents a PowerDisplay profile containing monitor settings
|
||||
/// </summary>
|
||||
public class PowerDisplayProfile
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; }
|
||||
|
||||
[JsonPropertyName("monitorSettings")]
|
||||
public List<ProfileMonitorSetting> 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<ProfileMonitorSetting>();
|
||||
CreatedDate = DateTime.UtcNow;
|
||||
LastModified = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public PowerDisplayProfile(string name, List<ProfileMonitorSetting> monitorSettings)
|
||||
{
|
||||
Name = name;
|
||||
MonitorSettings = monitorSettings ?? new List<ProfileMonitorSetting>();
|
||||
CreatedDate = DateTime.UtcNow;
|
||||
LastModified = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that the profile has at least one monitor configured
|
||||
/// </summary>
|
||||
public bool IsValid()
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(Name) && MonitorSettings != null && MonitorSettings.Count > 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the last modified timestamp
|
||||
/// </summary>
|
||||
public void Touch()
|
||||
{
|
||||
LastModified = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Container for all PowerDisplay profiles
|
||||
/// </summary>
|
||||
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<PowerDisplayProfile> Profiles { get; set; }
|
||||
|
||||
[JsonPropertyName("lastUpdated")]
|
||||
public DateTime LastUpdated { get; set; }
|
||||
|
||||
public PowerDisplayProfiles()
|
||||
{
|
||||
Profiles = new List<PowerDisplayProfile>();
|
||||
LastUpdated = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the profile by name
|
||||
/// </summary>
|
||||
public PowerDisplayProfile? GetProfile(string name)
|
||||
{
|
||||
return Profiles.FirstOrDefault(p => p.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds or updates a profile
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a profile by name
|
||||
/// </summary>
|
||||
public bool RemoveProfile(string name)
|
||||
{
|
||||
var profile = GetProfile(name);
|
||||
if (profile != null)
|
||||
{
|
||||
Profiles.Remove(profile);
|
||||
LastUpdated = DateTime.UtcNow;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a profile name is valid and available
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Monitor settings for a specific profile
|
||||
/// </summary>
|
||||
public class ProfileMonitorSetting
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the monitor's unique identifier.
|
||||
/// Format: "{Source}_{EdidId}_{MonitorNumber}" (e.g., "DDC_GSM5C6D_1").
|
||||
/// </summary>
|
||||
[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; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the color temperature VCP preset value.
|
||||
/// JSON property name kept as "colorTemperature" for backward compatibility.
|
||||
/// </summary>
|
||||
[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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace PowerDisplay.Common.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents a pending profile operation to be applied by PowerDisplay
|
||||
/// </summary>
|
||||
public class ProfileOperation
|
||||
{
|
||||
[JsonPropertyName("profileName")]
|
||||
public string ProfileName { get; set; }
|
||||
|
||||
[JsonPropertyName("monitorSettings")]
|
||||
public List<ProfileMonitorSetting> MonitorSettings { get; set; }
|
||||
|
||||
public ProfileOperation()
|
||||
{
|
||||
ProfileName = string.Empty;
|
||||
MonitorSettings = new List<ProfileMonitorSetting>();
|
||||
}
|
||||
|
||||
public ProfileOperation(string profileName, List<ProfileMonitorSetting> monitorSettings)
|
||||
{
|
||||
ProfileName = profileName;
|
||||
MonitorSettings = monitorSettings ?? new List<ProfileMonitorSetting>();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// DDC/CI VCP capabilities information
|
||||
/// </summary>
|
||||
public class VcpCapabilities
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets raw capabilities string (MCCS format)
|
||||
/// </summary>
|
||||
public string Raw { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets monitor model name from capabilities
|
||||
/// </summary>
|
||||
public string? Model { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets monitor type from capabilities (e.g., "LCD")
|
||||
/// </summary>
|
||||
public string? Type { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets mCCS protocol version
|
||||
/// </summary>
|
||||
public string? Protocol { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets mCCS version (e.g., "2.2", "2.1")
|
||||
/// </summary>
|
||||
public string? MccsVersion { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets supported command codes
|
||||
/// </summary>
|
||||
public List<byte> SupportedCommands { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets supported VCP codes with their information
|
||||
/// </summary>
|
||||
public Dictionary<byte, VcpCodeInfo> SupportedVcpCodes { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets window capabilities for PIP/PBP support
|
||||
/// </summary>
|
||||
public List<WindowCapability> Windows { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether check if display supports PIP/PBP windows
|
||||
/// </summary>
|
||||
public bool HasWindowSupport => Windows.Count > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Check if a specific VCP code is supported
|
||||
/// </summary>
|
||||
public bool SupportsVcpCode(byte code) => SupportedVcpCodes.ContainsKey(code);
|
||||
|
||||
/// <summary>
|
||||
/// Get VCP code information
|
||||
/// </summary>
|
||||
public VcpCodeInfo? GetVcpCodeInfo(byte code)
|
||||
{
|
||||
return SupportedVcpCodes.TryGetValue(code, out var info) ? info : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if a VCP code supports discrete values
|
||||
/// </summary>
|
||||
public bool HasDiscreteValues(byte code)
|
||||
{
|
||||
var info = GetVcpCodeInfo(code);
|
||||
return info?.HasDiscreteValues ?? false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get supported values for a VCP code
|
||||
/// </summary>
|
||||
public IReadOnlyList<int>? GetSupportedValues(byte code)
|
||||
{
|
||||
return GetVcpCodeInfo(code)?.SupportedValues;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get all VCP codes as hex strings, sorted by code value.
|
||||
/// </summary>
|
||||
/// <returns>List of hex strings like ["0x10", "0x12", "0x14"]</returns>
|
||||
public List<string> GetVcpCodesAsHexStrings()
|
||||
{
|
||||
var result = new List<string>(SupportedVcpCodes.Count);
|
||||
foreach (var kvp in SupportedVcpCodes)
|
||||
{
|
||||
result.Add($"0x{kvp.Key:X2}");
|
||||
}
|
||||
|
||||
result.Sort(StringComparer.Ordinal);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get all VCP codes sorted by code value.
|
||||
/// </summary>
|
||||
/// <returns>Sorted list of VcpCodeInfo</returns>
|
||||
public IEnumerable<VcpCodeInfo> GetSortedVcpCodes()
|
||||
{
|
||||
var sortedKeys = new List<byte>(SupportedVcpCodes.Keys);
|
||||
sortedKeys.Sort();
|
||||
|
||||
foreach (var key in sortedKeys)
|
||||
{
|
||||
yield return SupportedVcpCodes[key];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets creates an empty capabilities object
|
||||
/// </summary>
|
||||
public static VcpCapabilities Empty => new();
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return $"Model: {Model}, VCP Codes: {SupportedVcpCodes.Count}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about a single VCP code
|
||||
/// </summary>
|
||||
public readonly struct VcpCodeInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets vCP code (e.g., 0x10 for brightness)
|
||||
/// </summary>
|
||||
public byte Code { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets human-readable name of the VCP code
|
||||
/// </summary>
|
||||
public string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets supported discrete values (empty if continuous range)
|
||||
/// </summary>
|
||||
public IReadOnlyList<int> SupportedValues { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether this VCP code has discrete values
|
||||
/// </summary>
|
||||
public bool HasDiscreteValues => SupportedValues.Count > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether this VCP code supports a continuous range
|
||||
/// </summary>
|
||||
public bool IsContinuous => SupportedValues.Count == 0;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the VCP code formatted as a hex string (e.g., "0x10").
|
||||
/// </summary>
|
||||
public string FormattedCode => $"0x{Code:X2}";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the VCP code formatted with its name (e.g., "Brightness (0x10)").
|
||||
/// </summary>
|
||||
public string FormattedTitle => $"{Name} ({FormattedCode})";
|
||||
|
||||
public VcpCodeInfo(byte code, string name, IReadOnlyList<int>? supportedValues = null)
|
||||
{
|
||||
Code = code;
|
||||
Name = name;
|
||||
SupportedValues = supportedValues ?? Array.Empty<int>();
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
if (HasDiscreteValues)
|
||||
{
|
||||
return $"0x{Code:X2} ({Name}): {string.Join(", ", SupportedValues)}";
|
||||
}
|
||||
|
||||
return $"0x{Code:X2} ({Name}): Continuous";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Window size (width and height)
|
||||
/// </summary>
|
||||
public readonly struct WindowSize
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets width in pixels
|
||||
/// </summary>
|
||||
public int Width { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets height in pixels
|
||||
/// </summary>
|
||||
public int Height { get; }
|
||||
|
||||
public WindowSize(int width, int height)
|
||||
{
|
||||
Width = width;
|
||||
Height = height;
|
||||
}
|
||||
|
||||
public override string ToString() => $"{Width}x{Height}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Window area coordinates (top-left and bottom-right)
|
||||
/// </summary>
|
||||
public readonly struct WindowArea
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets top-left X coordinate
|
||||
/// </summary>
|
||||
public int X1 { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets top-left Y coordinate
|
||||
/// </summary>
|
||||
public int Y1 { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets bottom-right X coordinate
|
||||
/// </summary>
|
||||
public int X2 { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets bottom-right Y coordinate
|
||||
/// </summary>
|
||||
public int Y2 { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets width of the area
|
||||
/// </summary>
|
||||
public int Width => X2 - X1;
|
||||
|
||||
/// <summary>
|
||||
/// Gets height of the area
|
||||
/// </summary>
|
||||
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})";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Window capability information for PIP/PBP displays
|
||||
/// </summary>
|
||||
public readonly struct WindowCapability
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets window number (1, 2, 3, etc.)
|
||||
/// </summary>
|
||||
public int WindowNumber { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets window type (e.g., "PIP", "PBP")
|
||||
/// </summary>
|
||||
public string Type { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets window area coordinates
|
||||
/// </summary>
|
||||
public WindowArea Area { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets maximum window size
|
||||
/// </summary>
|
||||
public WindowSize MaxSize { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets minimum window size
|
||||
/// </summary>
|
||||
public WindowSize MinSize { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets window identifier
|
||||
/// </summary>
|
||||
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}";
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// VCP feature value information structure.
|
||||
/// Represents the current, minimum, and maximum values for a VCP (Virtual Control Panel) feature.
|
||||
/// </summary>
|
||||
public readonly struct VcpFeatureValue
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets current value
|
||||
/// </summary>
|
||||
public int Current { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets minimum value
|
||||
/// </summary>
|
||||
public int Minimum { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets maximum value
|
||||
/// </summary>
|
||||
public int Maximum { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the value information is valid
|
||||
/// </summary>
|
||||
public bool IsValid { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets timestamp when the value information was obtained
|
||||
/// </summary>
|
||||
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)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets creates invalid value information
|
||||
/// </summary>
|
||||
public static VcpFeatureValue Invalid => new(-1, -1, -1);
|
||||
|
||||
/// <summary>
|
||||
/// Converts value to percentage (0-100)
|
||||
/// </summary>
|
||||
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";
|
||||
}
|
||||
}
|
||||
}
|
||||
122
src/modules/powerdisplay/PowerDisplay.Lib/PathConstants.cs
Normal file
122
src/modules/powerdisplay/PowerDisplay.Lib/PathConstants.cs
Normal file
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Centralized path constants for PowerDisplay module.
|
||||
/// Provides unified access to all file and folder paths used by PowerDisplay and related integrations.
|
||||
/// </summary>
|
||||
public static class PathConstants
|
||||
{
|
||||
private static readonly Lazy<string> _localAppDataPath = new Lazy<string>(
|
||||
() => Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData));
|
||||
|
||||
private static readonly Lazy<string> _powerToysBasePath = new Lazy<string>(
|
||||
() => Path.Combine(_localAppDataPath.Value, "Microsoft", "PowerToys"));
|
||||
|
||||
/// <summary>
|
||||
/// Gets the base PowerToys settings folder path.
|
||||
/// Example: C:\Users\{User}\AppData\Local\Microsoft\PowerToys
|
||||
/// </summary>
|
||||
public static string PowerToysBasePath => _powerToysBasePath.Value;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the PowerDisplay module folder path.
|
||||
/// Example: C:\Users\{User}\AppData\Local\Microsoft\PowerToys\PowerDisplay
|
||||
/// </summary>
|
||||
public static string PowerDisplayFolderPath => Path.Combine(PowerToysBasePath, "PowerDisplay");
|
||||
|
||||
/// <summary>
|
||||
/// Gets the PowerDisplay profiles file path.
|
||||
/// Example: C:\Users\{User}\AppData\Local\Microsoft\PowerToys\PowerDisplay\profiles.json
|
||||
/// </summary>
|
||||
public static string ProfilesFilePath => Path.Combine(PowerDisplayFolderPath, ProfilesFileName);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the PowerDisplay settings file path.
|
||||
/// Example: C:\Users\{User}\AppData\Local\Microsoft\PowerToys\PowerDisplay\settings.json
|
||||
/// </summary>
|
||||
public static string SettingsFilePath => Path.Combine(PowerDisplayFolderPath, SettingsFileName);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the LightSwitch module folder path.
|
||||
/// Example: C:\Users\{User}\AppData\Local\Microsoft\PowerToys\LightSwitch
|
||||
/// </summary>
|
||||
public static string LightSwitchFolderPath => Path.Combine(PowerToysBasePath, "LightSwitch");
|
||||
|
||||
/// <summary>
|
||||
/// Gets the LightSwitch settings file path.
|
||||
/// Example: C:\Users\{User}\AppData\Local\Microsoft\PowerToys\LightSwitch\settings.json
|
||||
/// </summary>
|
||||
public static string LightSwitchSettingsFilePath => Path.Combine(LightSwitchFolderPath, SettingsFileName);
|
||||
|
||||
/// <summary>
|
||||
/// The name of the profiles file.
|
||||
/// </summary>
|
||||
public const string ProfilesFileName = "profiles.json";
|
||||
|
||||
/// <summary>
|
||||
/// The name of the settings file.
|
||||
/// </summary>
|
||||
public const string SettingsFileName = "settings.json";
|
||||
|
||||
/// <summary>
|
||||
/// The name of the monitor state file.
|
||||
/// </summary>
|
||||
public const string MonitorStateFileName = "monitor_state.json";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the monitor state file path.
|
||||
/// Example: C:\Users\{User}\AppData\Local\Microsoft\PowerToys\PowerDisplay\monitor_state.json
|
||||
/// </summary>
|
||||
public static string MonitorStateFilePath => Path.Combine(PowerDisplayFolderPath, MonitorStateFileName);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public const string LightSwitchLightThemeEventName = "Local\\PowerToysLightSwitch-LightThemeEvent-50077121-2ffc-4841-9c86-ab1bd3f9baca";
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public const string LightSwitchDarkThemeEventName = "Local\\PowerToysLightSwitch-DarkThemeEvent-b3a835c0-eaa2-49b0-b8eb-f793e3df3368";
|
||||
|
||||
/// <summary>
|
||||
/// Ensures the PowerDisplay folder exists. Creates it if necessary.
|
||||
/// </summary>
|
||||
/// <returns>The PowerDisplay folder path</returns>
|
||||
public static string EnsurePowerDisplayFolderExists()
|
||||
=> EnsureFolderExists(PowerDisplayFolderPath);
|
||||
|
||||
/// <summary>
|
||||
/// Ensures the LightSwitch folder exists. Creates it if necessary.
|
||||
/// </summary>
|
||||
/// <returns>The LightSwitch folder path</returns>
|
||||
public static string EnsureLightSwitchFolderExists()
|
||||
=> EnsureFolderExists(LightSwitchFolderPath);
|
||||
|
||||
/// <summary>
|
||||
/// Ensures the specified folder exists. Creates it if necessary.
|
||||
/// </summary>
|
||||
/// <param name="folderPath">The folder path to ensure exists</param>
|
||||
/// <returns>The folder path</returns>
|
||||
private static string EnsureFolderExists(string folderPath)
|
||||
{
|
||||
if (!Directory.Exists(folderPath))
|
||||
{
|
||||
Directory.CreateDirectory(folderPath);
|
||||
}
|
||||
|
||||
return folderPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<!-- Copyright (c) Microsoft Corporation. All rights reserved. -->
|
||||
<!-- Licensed under the MIT License. See LICENSE file in the project root for license information. -->
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<!-- Look at Directory.Build.props in root for common stuff as well -->
|
||||
<Import Project="..\..\..\Common.Dotnet.CsWinRT.props" />
|
||||
<Import Project="..\..\..\Common.Dotnet.AotCompatibility.props" />
|
||||
<PropertyGroup>
|
||||
<OutputType>Library</OutputType>
|
||||
<RootNamespace>PowerDisplay.Common</RootNamespace>
|
||||
<Platforms>x64;ARM64</Platforms>
|
||||
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
<Nullable>enable</Nullable>
|
||||
<AssemblyName>PowerDisplay.Lib</AssemblyName>
|
||||
</PropertyGroup>
|
||||
<!-- Native AOT Configuration -->
|
||||
<PropertyGroup>
|
||||
<PublishSingleFile>false</PublishSingleFile>
|
||||
<DisableRuntimeMarshalling>false</DisableRuntimeMarshalling>
|
||||
<IsAotCompatible>true</IsAotCompatible>
|
||||
<TrimmerSingleWarn>false</TrimmerSingleWarn>
|
||||
<SuppressTrimAnalysisWarnings>false</SuppressTrimAnalysisWarnings>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Polly.Core" />
|
||||
<PackageReference Include="WmiLight" />
|
||||
<PackageReference Include="System.Collections.Immutable" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<!-- Hide build log files from Solution Explorer -->
|
||||
<None Remove="*.log" />
|
||||
<None Remove="*.binlog" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\common\ManagedCommon\ManagedCommon.csproj" />
|
||||
<ProjectReference Include="..\..\..\common\ManagedCsWin32\ManagedCsWin32.csproj" />
|
||||
<ProjectReference Include="..\..\..\common\interop\PowerToys.Interop.vcxproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,38 @@
|
||||
// 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
|
||||
{
|
||||
/// <summary>
|
||||
/// JSON serialization context for PowerDisplay Profile types.
|
||||
/// Provides source-generated serialization for Native AOT compatibility.
|
||||
/// </summary>
|
||||
[JsonSourceGenerationOptions(
|
||||
WriteIndented = false,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
|
||||
IncludeFields = true)]
|
||||
|
||||
// Profile Types
|
||||
[JsonSerializable(typeof(ProfileMonitorSetting))]
|
||||
[JsonSerializable(typeof(List<ProfileMonitorSetting>))]
|
||||
[JsonSerializable(typeof(PowerDisplayProfile))]
|
||||
[JsonSerializable(typeof(List<PowerDisplayProfile>))]
|
||||
[JsonSerializable(typeof(PowerDisplayProfiles))]
|
||||
[JsonSerializable(typeof(ProfileOperation))]
|
||||
[JsonSerializable(typeof(List<ProfileOperation>))]
|
||||
[JsonSerializable(typeof(ColorTemperatureOperation))]
|
||||
[JsonSerializable(typeof(List<ColorTemperatureOperation>))]
|
||||
|
||||
// Monitor State Types
|
||||
[JsonSerializable(typeof(MonitorStateEntry))]
|
||||
[JsonSerializable(typeof(MonitorStateFile))]
|
||||
[JsonSerializable(typeof(Dictionary<string, MonitorStateEntry>))]
|
||||
public partial class ProfileSerializationContext : JsonSerializerContext
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using ManagedCommon;
|
||||
using PowerDisplay.Common.Models;
|
||||
using static PowerDisplay.Common.Drivers.NativeConstants;
|
||||
using static PowerDisplay.Common.Drivers.PInvoke;
|
||||
|
||||
using DevMode = PowerDisplay.Common.Drivers.DevMode;
|
||||
|
||||
namespace PowerDisplay.Common.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Service for controlling display rotation/orientation.
|
||||
/// Uses ChangeDisplaySettingsEx API to change display orientation.
|
||||
/// </summary>
|
||||
public class DisplayRotationService
|
||||
{
|
||||
/// <summary>
|
||||
/// Set display rotation for a specific monitor.
|
||||
/// Uses GdiDeviceName from the Monitor object for accurate adapter targeting.
|
||||
/// </summary>
|
||||
/// <param name="monitor">Monitor object with GdiDeviceName</param>
|
||||
/// <param name="newOrientation">New orientation: 0=normal, 1=90°, 2=180°, 3=270°</param>
|
||||
/// <returns>Operation result</returns>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set display rotation by GDI device name.
|
||||
/// </summary>
|
||||
/// <param name="gdiDeviceName">GDI device name (e.g., "\\.\DISPLAY1")</param>
|
||||
/// <param name="newOrientation">New orientation: 0=normal, 1=90°, 2=180°, 3=270°</param>
|
||||
/// <returns>Operation result</returns>
|
||||
public unsafe MonitorOperationResult SetRotationByGdiDeviceName(string gdiDeviceName, int newOrientation)
|
||||
{
|
||||
if (string.IsNullOrEmpty(gdiDeviceName))
|
||||
{
|
||||
return MonitorOperationResult.Failure("GDI device name is required");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Logger.LogInfo($"SetRotation: Setting {gdiDeviceName} to orientation {newOrientation}");
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
Logger.LogInfo($"SetRotation: Successfully set {gdiDeviceName} to orientation {newOrientation}");
|
||||
return MonitorOperationResult.Success();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError($"SetRotation: Exception for {gdiDeviceName}: {ex.Message}");
|
||||
return MonitorOperationResult.Failure($"Exception while setting rotation: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get current orientation for a GDI device name.
|
||||
/// </summary>
|
||||
/// <param name="gdiDeviceName">GDI device name (e.g., "\\.\DISPLAY1")</param>
|
||||
/// <returns>Current orientation (0-3), or -1 if query failed</returns>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get human-readable error message for ChangeDisplaySettings result code.
|
||||
/// </summary>
|
||||
private static string GetChangeDisplaySettingsErrorMessage(int resultCode)
|
||||
{
|
||||
return resultCode switch
|
||||
{
|
||||
DispChangeSuccessful => "Success",
|
||||
DispChangeRestart => "Computer must be restarted",
|
||||
DispChangeFailed => "Display driver failed the specified graphics mode",
|
||||
DispChangeBadmode => "Graphics mode is not supported",
|
||||
DispChangeNotupdated => "Unable to write settings to registry",
|
||||
DispChangeBadflags => "Invalid flags",
|
||||
DispChangeBadparam => "Invalid parameter",
|
||||
_ => $"Unknown error code: {resultCode}",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
// 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.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
|
||||
{
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
public partial class MonitorStateManager : IDisposable
|
||||
{
|
||||
private readonly string _stateFilePath;
|
||||
private readonly ConcurrentDictionary<string, MonitorState> _states = new();
|
||||
private readonly object _statesLock = 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
|
||||
|
||||
/// <summary>
|
||||
/// Monitor state data (internal tracking, not serialized)
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MonitorStateManager"/> class.
|
||||
/// Uses PathConstants for consistent path management.
|
||||
/// </summary>
|
||||
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();
|
||||
|
||||
Logger.LogInfo($"MonitorStateManager initialized with debounced-save strategy (debounce: {SaveDebounceMs}ms), state file: {_stateFilePath}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
/// <param name="monitorId">The monitor's unique Id (e.g., "DDC_GSM5C6D_1").</param>
|
||||
/// <param name="property">The property name to update (Brightness, ColorTemperature, Contrast, or Volume).</param>
|
||||
/// <param name="value">The new value.</param>
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get saved parameters for a monitor using Monitor.Id.
|
||||
/// </summary>
|
||||
/// <param name="monitorId">The monitor's unique Id (e.g., "DDC_GSM5C6D_1").</param>
|
||||
/// <returns>A tuple of (Brightness, ColorTemperatureVcp, Contrast, Volume) or null if not found.</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Load state from disk.
|
||||
/// </summary>
|
||||
private void LoadStateFromDisk()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(_stateFilePath))
|
||||
{
|
||||
Logger.LogInfo("[State] No existing state file found, starting fresh");
|
||||
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 HardwareId (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,
|
||||
};
|
||||
}
|
||||
|
||||
Logger.LogInfo($"[State] Loaded state for {stateFile.Monitors.Count} monitors from {_stateFilePath}");
|
||||
Logger.LogInfo($"[State] Monitor keys in state file: {string.Join(", ", stateFile.Monitors.Keys)}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError($"Failed to load monitor state: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Save current state to disk immediately (async).
|
||||
/// Called by timer after debounce period.
|
||||
/// </summary>
|
||||
private async Task SaveStateToDiskAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var (json, monitorCount) = 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}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Save current state to disk synchronously.
|
||||
/// Called during Dispose to flush pending changes without risk of deadlock.
|
||||
/// </summary>
|
||||
private void SaveStateToDiskSync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var (json, monitorCount) = 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}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the JSON string for state file.
|
||||
/// Shared logic between async and sync save methods.
|
||||
/// </summary>
|
||||
/// <returns>Tuple of (JSON string, monitor count)</returns>
|
||||
private (string Json, int MonitorCount) 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,
|
||||
};
|
||||
}
|
||||
|
||||
var json = JsonSerializer.Serialize(stateFile, ProfileSerializationContext.Default.MonitorStateFile);
|
||||
return (json, stateFile.Monitors.Count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposes the MonitorStateManager, flushing any pending state changes.
|
||||
/// </summary>
|
||||
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)
|
||||
{
|
||||
Logger.LogInfo("Flushing pending state changes before dispose");
|
||||
SaveStateToDiskSync();
|
||||
}
|
||||
|
||||
Logger.LogInfo("MonitorStateManager disposed");
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.IO;
|
||||
using System.Text.Json;
|
||||
using ManagedCommon;
|
||||
using PowerDisplay.Common.Interfaces;
|
||||
using PowerDisplay.Common.Models;
|
||||
using PowerDisplay.Common.Serialization;
|
||||
|
||||
namespace PowerDisplay.Common.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class ProfileService : IProfileService
|
||||
{
|
||||
private const string LogPrefix = "[ProfileService]";
|
||||
private static readonly object _lock = new object();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the singleton instance of the ProfileService.
|
||||
/// Use this for dependency injection or when interface-based access is needed.
|
||||
/// </summary>
|
||||
public static IProfileService Instance { get; } = new ProfileService();
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ProfileService"/> class.
|
||||
/// Private constructor to enforce singleton pattern for instance-based access.
|
||||
/// Static methods remain available for backward compatibility.
|
||||
/// </summary>
|
||||
private ProfileService()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads PowerDisplay profiles from disk.
|
||||
/// Thread-safe operation with automatic legacy profile cleanup.
|
||||
/// </summary>
|
||||
/// <returns>PowerDisplayProfiles object, or a new empty instance if file doesn't exist or load fails</returns>
|
||||
public static PowerDisplayProfiles LoadProfiles()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var (profiles, _) = LoadProfilesInternal();
|
||||
return profiles;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves PowerDisplay profiles to disk.
|
||||
/// Thread-safe operation with automatic timestamp update and legacy profile cleanup.
|
||||
/// </summary>
|
||||
/// <param name="profiles">The profiles collection to save</param>
|
||||
/// <returns>True if save was successful, false otherwise</returns>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds or updates a profile in the collection and persists to disk.
|
||||
/// Thread-safe operation.
|
||||
/// </summary>
|
||||
/// <param name="profile">The profile to add or update</param>
|
||||
/// <returns>True if operation was successful, false otherwise</returns>
|
||||
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);
|
||||
|
||||
if (success)
|
||||
{
|
||||
Logger.LogInfo($"{LogPrefix} Profile '{profile.Name}' added/updated with {profile.MonitorSettings.Count} monitors");
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a profile by name and persists to disk.
|
||||
/// Thread-safe operation.
|
||||
/// </summary>
|
||||
/// <param name="profileName">The name of the profile to remove</param>
|
||||
/// <returns>True if profile was found and removed, false otherwise</returns>
|
||||
public static bool RemoveProfile(string profileName)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var (profiles, _) = LoadProfilesInternal();
|
||||
bool removed = profiles.RemoveProfile(profileName);
|
||||
|
||||
if (removed)
|
||||
{
|
||||
SaveProfilesInternal(profiles);
|
||||
Logger.LogInfo($"{LogPrefix} Profile '{profileName}' removed");
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.LogWarning($"{LogPrefix} Profile '{profileName}' not found or cannot be removed");
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a profile by name.
|
||||
/// Thread-safe operation.
|
||||
/// </summary>
|
||||
/// <param name="profileName">The name of the profile to retrieve</param>
|
||||
/// <returns>The profile if found, null otherwise</returns>
|
||||
public static PowerDisplayProfile? GetProfile(string profileName)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var (profiles, _) = LoadProfilesInternal();
|
||||
return profiles.GetProfile(profileName);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the profiles file exists.
|
||||
/// </summary>
|
||||
/// <returns>True if profiles file exists, false otherwise</returns>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the path to the profiles file.
|
||||
/// </summary>
|
||||
/// <returns>The full path to the profiles file</returns>
|
||||
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
|
||||
|
||||
/// <inheritdoc/>
|
||||
PowerDisplayProfiles IProfileService.LoadProfiles() => LoadProfiles();
|
||||
|
||||
/// <inheritdoc/>
|
||||
bool IProfileService.SaveProfiles(PowerDisplayProfiles profiles) => SaveProfiles(profiles);
|
||||
|
||||
/// <inheritdoc/>
|
||||
bool IProfileService.AddOrUpdateProfile(PowerDisplayProfile profile) => AddOrUpdateProfile(profile);
|
||||
|
||||
/// <inheritdoc/>
|
||||
bool IProfileService.RemoveProfile(string profileName) => RemoveProfile(profileName);
|
||||
|
||||
/// <inheritdoc/>
|
||||
PowerDisplayProfile? IProfileService.GetProfile(string profileName) => GetProfile(profileName);
|
||||
|
||||
/// <inheritdoc/>
|
||||
bool IProfileService.ProfilesFileExists() => ProfilesFileExists();
|
||||
|
||||
/// <inheritdoc/>
|
||||
string IProfileService.GetProfilesFilePath() => GetProfilesFilePath();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Helper class for color temperature preset computation.
|
||||
/// Provides shared logic for computing available color presets from VCP capabilities.
|
||||
/// </summary>
|
||||
public static class ColorTemperatureHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Computes available color temperature presets from VCP value data.
|
||||
/// </summary>
|
||||
/// <param name="colorTemperatureValues">
|
||||
/// 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.
|
||||
/// </param>
|
||||
/// <returns>Sorted list of ColorPresetItem objects.</returns>
|
||||
public static List<ColorPresetItem> ComputeColorPresets(IEnumerable<(int VcpValue, string? Name)> colorTemperatureValues)
|
||||
{
|
||||
if (colorTemperatureValues == null)
|
||||
{
|
||||
return new List<ColorPresetItem>();
|
||||
}
|
||||
|
||||
var presetList = new List<ColorPresetItem>();
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats a color temperature display name.
|
||||
/// Uses VcpNames for standard VCP value mappings if no custom name is provided.
|
||||
/// </summary>
|
||||
/// <param name="vcpValue">The VCP value.</param>
|
||||
/// <param name="customName">Optional custom name from capabilities string.</param>
|
||||
/// <returns>Formatted display name.</returns>
|
||||
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";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats a display name for a custom (non-preset) color temperature value.
|
||||
/// Used when the current value is not in the available preset list.
|
||||
/// </summary>
|
||||
/// <param name="vcpValue">The VCP value.</param>
|
||||
/// <returns>Formatted display name with "Custom" indicator.</returns>
|
||||
public static string FormatCustomColorTemperatureDisplayName(int vcpValue)
|
||||
{
|
||||
var standardName = VcpNames.GetValueName(NativeConstants.VcpCodeSelectColorPreset, vcpValue);
|
||||
return string.IsNullOrEmpty(standardName)
|
||||
? "Custom"
|
||||
: $"{standardName} (Custom)";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Helper class for Windows named event operations.
|
||||
/// Provides unified event signaling with consistent error handling and logging.
|
||||
/// </summary>
|
||||
public static class EventHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Signals a named event. Creates the event if it doesn't exist.
|
||||
/// </summary>
|
||||
/// <param name="eventName">The name of the event to signal.</param>
|
||||
/// <returns>True if the event was signaled successfully, false otherwise.</returns>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Recursive descent parser for DDC/CI MCCS capabilities strings.
|
||||
///
|
||||
/// MCCS Capabilities String Grammar (BNF):
|
||||
/// <code>
|
||||
/// 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 ::= [^()]+
|
||||
/// </code>
|
||||
///
|
||||
/// 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))
|
||||
/// </summary>
|
||||
public ref struct MccsCapabilitiesParser
|
||||
{
|
||||
private readonly List<ParseError> _errors;
|
||||
private ReadOnlySpan<char> _input;
|
||||
private int _position;
|
||||
|
||||
/// <summary>
|
||||
/// Parse a capabilities string into structured VcpCapabilities.
|
||||
/// </summary>
|
||||
/// <param name="capabilitiesString">Raw MCCS capabilities string</param>
|
||||
/// <returns>Parsed capabilities object with any parse errors</returns>
|
||||
public static MccsParseResult Parse(string? capabilitiesString)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(capabilitiesString))
|
||||
{
|
||||
return new MccsParseResult(VcpCapabilities.Empty, new List<ParseError>());
|
||||
}
|
||||
|
||||
var parser = new MccsCapabilitiesParser(capabilitiesString);
|
||||
return parser.ParseCapabilities();
|
||||
}
|
||||
|
||||
private MccsCapabilitiesParser(string input)
|
||||
{
|
||||
_input = input.AsSpan();
|
||||
_position = 0;
|
||||
_errors = new List<ParseError>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Main entry point: parse the entire capabilities string.
|
||||
/// capabilities ::= '(' segment* ')' | segment*
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse a single segment: identifier '(' content ')'
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse content between balanced parentheses.
|
||||
/// Handles nested parentheses correctly.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse an identifier (letters, digits, and underscores).
|
||||
/// identifier ::= [a-zA-Z0-9_]+
|
||||
/// Note: MCCS uses identifiers like window1, window2, etc.
|
||||
/// </summary>
|
||||
private ReadOnlySpan<char> ParseIdentifier()
|
||||
{
|
||||
int start = _position;
|
||||
|
||||
while (!IsAtEnd() && IsIdentifierChar(Peek()))
|
||||
{
|
||||
Advance();
|
||||
}
|
||||
|
||||
return _input.Slice(start, _position - start);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Apply a parsed segment to the capabilities object.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse VCP entries: vcp_entry*
|
||||
/// vcp_entry ::= hex_byte [ '(' hex_list ')' ]
|
||||
/// </summary>
|
||||
private Dictionary<byte, VcpCodeInfo> ParseVcpEntries(string content)
|
||||
{
|
||||
var vcpCodes = new Dictionary<byte, VcpCodeInfo>();
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse a hex byte list: hex_byte*
|
||||
/// Handles both space-separated (01 02 03) and concatenated (010203) formats.
|
||||
/// </summary>
|
||||
private static List<byte> ParseHexList(string content)
|
||||
{
|
||||
var result = new List<byte>();
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse vcpname entries: hex_byte '(' name ')'
|
||||
/// </summary>
|
||||
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<int>());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[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}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sub-parser for VCP entries within the vcp() segment.
|
||||
/// </summary>
|
||||
internal ref struct VcpEntryParser
|
||||
{
|
||||
private ReadOnlySpan<char> _content;
|
||||
private int _position;
|
||||
|
||||
public VcpEntryParser(string content)
|
||||
{
|
||||
_content = content.AsSpan();
|
||||
_position = 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Try to parse the next VCP entry.
|
||||
/// vcp_entry ::= hex_byte [ '(' hex_list ')' ]
|
||||
/// </summary>
|
||||
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<int>();
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sub-parser for vcpname entries.
|
||||
/// </summary>
|
||||
internal ref struct VcpNameParser
|
||||
{
|
||||
private ReadOnlySpan<char> _content;
|
||||
private int _position;
|
||||
|
||||
public VcpNameParser(string content)
|
||||
{
|
||||
_content = content.AsSpan();
|
||||
_position = 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Try to parse the next vcpname entry.
|
||||
/// vcpname_entry ::= hex_byte '(' name ')'
|
||||
/// </summary>
|
||||
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');
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sub-parser for window segment content.
|
||||
/// Parses: type(PIP) area(25 25 1895 1175) max(640 480) min(10 10) window(10)
|
||||
/// </summary>
|
||||
internal ref struct WindowParser
|
||||
{
|
||||
private ReadOnlySpan<char> _content;
|
||||
private int _position;
|
||||
|
||||
public WindowParser(string content)
|
||||
{
|
||||
_content = content.AsSpan();
|
||||
_position = 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse window segment content into a WindowCapability.
|
||||
/// </summary>
|
||||
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<int>(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 == '_';
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a parsed segment from the capabilities string.
|
||||
/// </summary>
|
||||
internal readonly struct ParsedSegment
|
||||
{
|
||||
public string Name { get; }
|
||||
|
||||
public string Content { get; }
|
||||
|
||||
public ParsedSegment(string name, string content)
|
||||
{
|
||||
Name = name;
|
||||
Content = content;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a parsed VCP entry.
|
||||
/// </summary>
|
||||
internal readonly struct VcpEntry
|
||||
{
|
||||
public byte Code { get; }
|
||||
|
||||
public IReadOnlyList<int> Values { get; }
|
||||
|
||||
public VcpEntry(byte code, IReadOnlyList<int> values)
|
||||
{
|
||||
Code = code;
|
||||
Values = values;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a parse error with position information.
|
||||
/// </summary>
|
||||
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}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of parsing MCCS capabilities string.
|
||||
/// </summary>
|
||||
public sealed class MccsParseResult
|
||||
{
|
||||
public VcpCapabilities Capabilities { get; }
|
||||
|
||||
public IReadOnlyList<ParseError> Errors { get; }
|
||||
|
||||
public bool HasErrors => Errors.Count > 0;
|
||||
|
||||
public bool IsValid => !HasErrors && Capabilities.SupportedVcpCodes.Count > 0;
|
||||
|
||||
public MccsParseResult(VcpCapabilities capabilities, IReadOnlyList<ParseError> errors)
|
||||
{
|
||||
Capabilities = capabilities;
|
||||
Errors = errors;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
// 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 PowerDisplay.Common.Drivers;
|
||||
|
||||
namespace PowerDisplay.Common.Utils
|
||||
{
|
||||
/// <summary>
|
||||
/// Unified helper class for parsing monitor feature support from VCP capabilities.
|
||||
/// This eliminates duplicate VCP parsing logic across PowerDisplay.exe and Settings.UI.
|
||||
/// </summary>
|
||||
public static class MonitorFeatureHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Result of parsing monitor feature support from VCP capabilities
|
||||
/// </summary>
|
||||
public readonly struct FeatureSupportResult
|
||||
{
|
||||
public bool SupportsBrightness { get; init; }
|
||||
|
||||
public bool SupportsContrast { get; init; }
|
||||
|
||||
public bool SupportsColorTemperature { get; init; }
|
||||
|
||||
public bool SupportsVolume { get; init; }
|
||||
|
||||
public bool SupportsInputSource { get; init; }
|
||||
|
||||
public static FeatureSupportResult Unavailable => new()
|
||||
{
|
||||
SupportsBrightness = false,
|
||||
SupportsContrast = false,
|
||||
SupportsColorTemperature = false,
|
||||
SupportsVolume = false,
|
||||
SupportsInputSource = false,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse feature support from a list of VCP code strings.
|
||||
/// This is the single source of truth for determining monitor capabilities.
|
||||
/// </summary>
|
||||
/// <param name="vcpCodes">List of VCP codes as strings (e.g., "0x10", "10", "0x12")</param>
|
||||
/// <param name="capabilitiesRaw">Raw capabilities string, used to determine availability status</param>
|
||||
/// <returns>Feature support result</returns>
|
||||
public static FeatureSupportResult ParseFeatureSupport(IReadOnlyList<string>? vcpCodes, string? capabilitiesRaw)
|
||||
{
|
||||
// Check capabilities availability
|
||||
if (string.IsNullOrEmpty(capabilitiesRaw))
|
||||
{
|
||||
return FeatureSupportResult.Unavailable;
|
||||
}
|
||||
|
||||
// Convert all VCP codes to integers for comparison
|
||||
var vcpCodeInts = ParseVcpCodesToIntegers(vcpCodes);
|
||||
|
||||
// Determine feature support based on VCP codes
|
||||
return new FeatureSupportResult
|
||||
{
|
||||
SupportsBrightness = vcpCodeInts.Contains(NativeConstants.VcpCodeBrightness),
|
||||
SupportsContrast = vcpCodeInts.Contains(NativeConstants.VcpCodeContrast),
|
||||
SupportsColorTemperature = vcpCodeInts.Contains(NativeConstants.VcpCodeSelectColorPreset),
|
||||
SupportsVolume = vcpCodeInts.Contains(NativeConstants.VcpCodeVolume),
|
||||
SupportsInputSource = vcpCodeInts.Contains(NativeConstants.VcpCodeInputSource),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse VCP codes from string list to integer set
|
||||
/// Handles both hex formats: "0x10" and "10"
|
||||
/// </summary>
|
||||
private static HashSet<int> ParseVcpCodesToIntegers(IReadOnlyList<string>? vcpCodes)
|
||||
{
|
||||
var result = new HashSet<int>();
|
||||
|
||||
if (vcpCodes == null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
foreach (var code in vcpCodes)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(code))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Remove "0x" prefix if present and parse as hex
|
||||
var cleanCode = code.Trim();
|
||||
if (cleanCode.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
cleanCode = cleanCode[2..];
|
||||
}
|
||||
|
||||
if (int.TryParse(cleanCode, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out int codeInt))
|
||||
{
|
||||
result.Add(codeInt);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// 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 PowerDisplay.Common.Interfaces;
|
||||
|
||||
namespace PowerDisplay.Common.Utils
|
||||
{
|
||||
/// <summary>
|
||||
/// Helper class for monitor matching and identification.
|
||||
/// Provides consistent logic for matching monitors across different data sources.
|
||||
/// </summary>
|
||||
public static class MonitorMatchingHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Generate a unique key for monitor matching based on Id.
|
||||
/// </summary>
|
||||
/// <param name="monitor">The monitor data to generate a key for.</param>
|
||||
/// <returns>A unique string key for the monitor.</returns>
|
||||
public static string GetMonitorKey(IMonitorData? monitor)
|
||||
=> monitor?.Id ?? string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Check if two monitors are considered the same based on their Ids.
|
||||
/// </summary>
|
||||
/// <param name="monitor1">First monitor.</param>
|
||||
/// <param name="monitor2">Second monitor.</param>
|
||||
/// <returns>True if the monitors have the same Id.</returns>
|
||||
public static bool AreMonitorsSame(IMonitorData monitor1, IMonitorData monitor2)
|
||||
{
|
||||
if (monitor1 == null || monitor2 == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return !string.IsNullOrEmpty(monitor1.Id) && monitor1.Id == monitor2.Id;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
namespace PowerDisplay.Common.Utils
|
||||
{
|
||||
/// <summary>
|
||||
/// Provides conversion utilities for monitor hardware values.
|
||||
/// Use this class to convert between raw hardware values and display-friendly formats.
|
||||
/// </summary>
|
||||
public static class MonitorValueConverter
|
||||
{
|
||||
/// <summary>
|
||||
/// Formats a VCP color temperature value as a display name.
|
||||
/// </summary>
|
||||
/// <param name="vcpValue">The VCP preset value (e.g., 0x05).</param>
|
||||
/// <returns>Display name like "6500K" or "sRGB".</returns>
|
||||
public static string FormatColorTemperatureDisplay(int vcpValue)
|
||||
{
|
||||
return ColorTemperatureHelper.FormatColorTemperatureDisplayName(vcpValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// </summary>
|
||||
public static class PnpIdHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Map of common laptop/monitor manufacturer PnP IDs to display names.
|
||||
/// Only includes manufacturers known to produce laptops with internal displays.
|
||||
/// </summary>
|
||||
private static readonly FrozenDictionary<string, string> ManufacturerNames = new Dictionary<string, string>(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);
|
||||
|
||||
/// <summary>
|
||||
/// Extract the 3-character PnP manufacturer ID from a hardware ID.
|
||||
/// </summary>
|
||||
/// <param name="hardwareId">Hardware ID like "LEN4038" or "BOE0900".</param>
|
||||
/// <returns>The 3-character PnP ID (e.g., "LEN"), or null if invalid.</returns>
|
||||
public static string? ExtractPnpId(string? hardwareId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(hardwareId) || hardwareId.Length < 3)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// PnP ID is the first 3 characters
|
||||
return hardwareId.Substring(0, 3).ToUpperInvariant();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get a user-friendly display name for an internal display based on its hardware ID.
|
||||
/// </summary>
|
||||
/// <param name="hardwareId">Hardware ID like "LEN4038" or "BOE0900".</param>
|
||||
/// <returns>Display name like "Lenovo Built-in Display" or "Built-in Display" as fallback.</returns>
|
||||
public static string GetBuiltInDisplayName(string? hardwareId)
|
||||
{
|
||||
var pnpId = ExtractPnpId(hardwareId);
|
||||
|
||||
if (pnpId != null && ManufacturerNames.TryGetValue(pnpId, out var manufacturer))
|
||||
{
|
||||
return $"{manufacturer} Built-in Display";
|
||||
}
|
||||
|
||||
return "Built-in Display";
|
||||
}
|
||||
}
|
||||
@@ -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.Collections.Generic;
|
||||
|
||||
namespace PowerDisplay.Common.Utils
|
||||
{
|
||||
/// <summary>
|
||||
/// Helper class for profile management operations.
|
||||
/// Provides utilities for profile name generation and validation.
|
||||
/// </summary>
|
||||
public static class ProfileHelper
|
||||
{
|
||||
private const string DefaultProfileBaseName = "Profile";
|
||||
|
||||
/// <summary>
|
||||
/// Generate a unique profile name that doesn't conflict with existing names.
|
||||
/// </summary>
|
||||
/// <param name="existingNames">Set of existing profile names.</param>
|
||||
/// <param name="baseName">The base name to use (default: "Profile").</param>
|
||||
/// <returns>A unique profile name like "Profile 1", "Profile 2", etc.</returns>
|
||||
public static string GenerateUniqueProfileName(ISet<string>? existingNames, string baseName = DefaultProfileBaseName)
|
||||
{
|
||||
if (existingNames == null || existingNames.Count == 0)
|
||||
{
|
||||
return $"{baseName} 1";
|
||||
}
|
||||
|
||||
int counter = 1;
|
||||
string name;
|
||||
do
|
||||
{
|
||||
name = $"{baseName} {counter}";
|
||||
counter++;
|
||||
}
|
||||
while (existingNames.Contains(name));
|
||||
|
||||
return name;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Simple debouncer that delays execution of an action until a quiet period.
|
||||
/// Replaces the complex PropertyUpdateQueue with a much simpler approach (KISS principle).
|
||||
/// </summary>
|
||||
public partial class SimpleDebouncer : IDisposable
|
||||
{
|
||||
private readonly int _delayMs;
|
||||
private readonly object _lock = new object();
|
||||
private CancellationTokenSource? _cts;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SimpleDebouncer"/> class.
|
||||
/// Create a debouncer with specified delay
|
||||
/// </summary>
|
||||
/// <param name="delayMs">Delay in milliseconds before executing action</param>
|
||||
public SimpleDebouncer(int delayMs = 300)
|
||||
{
|
||||
_delayMs = delayMs;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Debounce an async action. Cancels previous invocation if still pending.
|
||||
/// </summary>
|
||||
/// <param name="action">Async action to execute after delay</param>
|
||||
public void Debounce(Func<Task> action)
|
||||
{
|
||||
_ = DebounceAsync(action);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Debounce a synchronous action
|
||||
/// </summary>
|
||||
public void Debounce(Action action)
|
||||
{
|
||||
_ = DebounceAsync(() =>
|
||||
{
|
||||
action();
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
}
|
||||
|
||||
private async Task DebounceAsync(Func<Task> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
427
src/modules/powerdisplay/PowerDisplay.Lib/Utils/VcpNames.cs
Normal file
427
src/modules/powerdisplay/PowerDisplay.Lib/Utils/VcpNames.cs
Normal file
@@ -0,0 +1,427 @@
|
||||
// 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
|
||||
{
|
||||
/// <summary>
|
||||
/// 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").
|
||||
/// </summary>
|
||||
public static class VcpNames
|
||||
{
|
||||
/// <summary>
|
||||
/// VCP code to name mapping
|
||||
/// </summary>
|
||||
private static readonly Dictionary<byte, string> 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" },
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Get the friendly name for a VCP code
|
||||
/// </summary>
|
||||
/// <param name="code">VCP code (e.g., 0x10)</param>
|
||||
/// <returns>Friendly name, or hex representation if unknown</returns>
|
||||
public static string GetCodeName(byte code)
|
||||
{
|
||||
return CodeNames.TryGetValue(code, out var name) ? name : $"Unknown (0x{code:X2})";
|
||||
}
|
||||
|
||||
// Dictionary<VcpCode, Dictionary<Value, Name>>
|
||||
private static readonly Dictionary<byte, Dictionary<int, string>> ValueNames = new()
|
||||
{
|
||||
// 0x14: Select Color Preset
|
||||
[0x14] = new Dictionary<int, string>
|
||||
{
|
||||
[0x01] = "sRGB",
|
||||
[0x02] = "Display Native",
|
||||
[0x03] = "4000K",
|
||||
[0x04] = "5000K",
|
||||
[0x05] = "6500K",
|
||||
[0x06] = "7500K",
|
||||
[0x08] = "9300K",
|
||||
[0x09] = "10000K",
|
||||
[0x0A] = "11500K",
|
||||
[0x0B] = "User 1",
|
||||
[0x0C] = "User 2",
|
||||
[0x0D] = "User 3",
|
||||
},
|
||||
|
||||
// 0x60: Input Source
|
||||
[0x60] = new Dictionary<int, string>
|
||||
{
|
||||
[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<int, string>
|
||||
{
|
||||
[0x01] = "On",
|
||||
[0x02] = "Standby",
|
||||
[0x03] = "Suspend",
|
||||
[0x04] = "Off (DPM)",
|
||||
[0x05] = "Off (Hard)",
|
||||
},
|
||||
|
||||
// 0x8D: Audio Mute
|
||||
[0x8D] = new Dictionary<int, string>
|
||||
{
|
||||
[0x01] = "Muted",
|
||||
[0x02] = "Unmuted",
|
||||
},
|
||||
|
||||
// 0xDC: Display Application
|
||||
[0xDC] = new Dictionary<int, string>
|
||||
{
|
||||
[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<int, string>
|
||||
{
|
||||
[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<int, string>
|
||||
{
|
||||
[0x00] = "Mute",
|
||||
|
||||
// Other values are continuous
|
||||
},
|
||||
|
||||
// 0xDB: Image Mode (Dell monitors)
|
||||
[0xDB] = new Dictionary<int, string>
|
||||
{
|
||||
[0x00] = "Standard",
|
||||
[0x01] = "Multimedia",
|
||||
[0x02] = "Movie",
|
||||
[0x03] = "Game",
|
||||
[0x04] = "Sports",
|
||||
[0x05] = "Color Temperature",
|
||||
[0x06] = "Custom Color",
|
||||
[0x07] = "ComfortView",
|
||||
},
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Get human-readable name for a VCP value
|
||||
/// </summary>
|
||||
/// <param name="vcpCode">VCP code (e.g., 0x14)</param>
|
||||
/// <param name="value">Value to translate</param>
|
||||
/// <returns>Name string like "sRGB" or null if unknown</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get formatted display name for a VCP value (with hex value in parentheses)
|
||||
/// </summary>
|
||||
/// <param name="vcpCode">VCP code (e.g., 0x14)</param>
|
||||
/// <param name="value">Value to translate</param>
|
||||
/// <returns>Formatted string like "sRGB (0x01)" or "0x01" if unknown</returns>
|
||||
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}";
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 468 KiB |
@@ -0,0 +1,39 @@
|
||||
// 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
|
||||
{
|
||||
/// <summary>
|
||||
/// Application-wide constants and configuration values
|
||||
/// </summary>
|
||||
public static class AppConstants
|
||||
{
|
||||
/// <summary>
|
||||
/// UI layout and timing constants
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Debounce delay for slider controls in milliseconds
|
||||
/// </summary>
|
||||
public const int SliderDebounceDelayMs = 300;
|
||||
|
||||
/// <summary>
|
||||
/// Icon glyph for internal/laptop displays (WMI)
|
||||
/// </summary>
|
||||
public const string InternalMonitorGlyph = "\uE7F8";
|
||||
|
||||
/// <summary>
|
||||
/// Icon glyph for external monitors (DDC/CI)
|
||||
/// </summary>
|
||||
public const string ExternalMonitorGlyph = "\uE7F4";
|
||||
}
|
||||
}
|
||||
}
|
||||
9
src/modules/powerdisplay/PowerDisplay/GlobalUsings.cs
Normal file
9
src/modules/powerdisplay/PowerDisplay/GlobalUsings.cs
Normal file
@@ -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]
|
||||
@@ -0,0 +1,268 @@
|
||||
// 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 Windows.Devices.Display;
|
||||
using Windows.Devices.Enumeration;
|
||||
|
||||
namespace PowerDisplay.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Watches for display/monitor connection changes using WinRT DeviceWatcher.
|
||||
/// Triggers DisplayChanged event when monitors are added, removed, or updated.
|
||||
/// </summary>
|
||||
public sealed partial class DisplayChangeWatcher : IDisposable
|
||||
{
|
||||
private readonly DispatcherQueue _dispatcherQueue;
|
||||
private readonly TimeSpan _debounceDelay = TimeSpan.FromSeconds(1);
|
||||
|
||||
private DeviceWatcher? _deviceWatcher;
|
||||
private CancellationTokenSource? _debounceCts;
|
||||
private bool _isRunning;
|
||||
private bool _disposed;
|
||||
private bool _initialEnumerationComplete;
|
||||
|
||||
/// <summary>
|
||||
/// Event triggered when display configuration changes (after debounce period).
|
||||
/// </summary>
|
||||
public event EventHandler? DisplayChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DisplayChangeWatcher"/> class.
|
||||
/// </summary>
|
||||
/// <param name="dispatcherQueue">The dispatcher queue for UI thread marshalling.</param>
|
||||
public DisplayChangeWatcher(DispatcherQueue dispatcherQueue)
|
||||
{
|
||||
_dispatcherQueue = dispatcherQueue ?? throw new ArgumentNullException(nameof(dispatcherQueue));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the watcher is currently running.
|
||||
/// </summary>
|
||||
public bool IsRunning => _isRunning;
|
||||
|
||||
/// <summary>
|
||||
/// Starts watching for display changes.
|
||||
/// </summary>
|
||||
public void Start()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
if (_isRunning)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Get the device selector for display monitors
|
||||
string selector = DisplayMonitor.GetDeviceSelector();
|
||||
Logger.LogInfo($"[DisplayChangeWatcher] Using device selector: {selector}");
|
||||
|
||||
// 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();
|
||||
|
||||
Logger.LogInfo("[DisplayChangeWatcher] Started watching for display changes");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError($"[DisplayChangeWatcher] Failed to start: {ex.Message}");
|
||||
_isRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops watching for display changes.
|
||||
/// </summary>
|
||||
public void Stop()
|
||||
{
|
||||
if (!_isRunning || _deviceWatcher == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Cancel any pending debounce
|
||||
CancelDebounce();
|
||||
|
||||
// Stop the watcher
|
||||
_deviceWatcher.Stop();
|
||||
|
||||
Logger.LogInfo("[DisplayChangeWatcher] Stopped watching for display changes");
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
Logger.LogInfo($"[DisplayChangeWatcher] Display added: {args.Name}");
|
||||
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;
|
||||
}
|
||||
|
||||
Logger.LogInfo("[DisplayChangeWatcher] Display removed");
|
||||
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;
|
||||
Logger.LogInfo("[DisplayChangeWatcher] Initial enumeration completed, now responding to display changes");
|
||||
});
|
||||
}
|
||||
|
||||
private void OnWatcherStopped(DeviceWatcher sender, object args)
|
||||
{
|
||||
// Dispatch to UI thread to ensure thread-safe state access
|
||||
_dispatcherQueue.TryEnqueue(() =>
|
||||
{
|
||||
_isRunning = false;
|
||||
_initialEnumerationComplete = false;
|
||||
Logger.LogInfo("[DisplayChangeWatcher] Watcher stopped");
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schedules a DisplayChanged event with debouncing.
|
||||
/// Multiple rapid changes will only trigger one event after the debounce period.
|
||||
/// </summary>
|
||||
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)
|
||||
{
|
||||
Logger.LogInfo("[DisplayChangeWatcher] Triggering DisplayChanged event");
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposes resources used by the watcher.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
|
||||
// Stop watching
|
||||
Stop();
|
||||
|
||||
// Unsubscribe from events
|
||||
if (_deviceWatcher != null)
|
||||
{
|
||||
_deviceWatcher.Added -= OnDeviceAdded;
|
||||
_deviceWatcher.Removed -= OnDeviceRemoved;
|
||||
_deviceWatcher.Updated -= OnDeviceUpdated;
|
||||
_deviceWatcher.EnumerationCompleted -= OnEnumerationCompleted;
|
||||
_deviceWatcher.Stopped -= OnWatcherStopped;
|
||||
_deviceWatcher = null;
|
||||
}
|
||||
|
||||
// Cancel debounce
|
||||
CancelDebounce();
|
||||
|
||||
Logger.LogInfo("[DisplayChangeWatcher] Disposed");
|
||||
}
|
||||
}
|
||||
188
src/modules/powerdisplay/PowerDisplay/Helpers/HotkeyService.cs
Normal file
188
src/modules/powerdisplay/PowerDisplay/Helpers/HotkeyService.cs
Normal file
@@ -0,0 +1,188 @@
|
||||
// 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
|
||||
{
|
||||
/// <summary>
|
||||
/// Service for handling hotkey registration in-process.
|
||||
/// Uses RegisterHotKey Win32 API instead of Runner's centralized mechanism
|
||||
/// to avoid IPC timing issues (CmdPal pattern).
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initialize the hotkey service with a window handle.
|
||||
/// Must be called after window is created.
|
||||
/// </summary>
|
||||
/// <param name="window">The WinUI window to attach to.</param>
|
||||
public void Initialize(Microsoft.UI.Xaml.Window window)
|
||||
{
|
||||
_hwnd = WindowNative.GetWindowHandle(window);
|
||||
Logger.LogTrace($"[HotkeyService] Initialize: hwnd=0x{_hwnd:X}");
|
||||
|
||||
// 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);
|
||||
|
||||
Logger.LogTrace($"[HotkeyService] WndProc hooked, original=0x{_originalWndProc:X}");
|
||||
|
||||
// Register hotkey based on current settings
|
||||
ReloadSettings();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reload settings and re-register hotkey.
|
||||
/// Call this when settings change.
|
||||
/// </summary>
|
||||
public void ReloadSettings()
|
||||
{
|
||||
Logger.LogTrace("[HotkeyService] ReloadSettings called");
|
||||
UnregisterHotkey();
|
||||
|
||||
var settings = _settingsUtils.GetSettingsOrDefault<PowerDisplaySettings>(PowerDisplaySettings.ModuleName);
|
||||
var hotkey = settings?.Properties?.ActivationShortcut;
|
||||
|
||||
if (hotkey == null || !hotkey.IsValid())
|
||||
{
|
||||
Logger.LogInfo("[HotkeyService] No valid hotkey configured");
|
||||
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;
|
||||
Logger.LogInfo($"[HotkeyService] Hotkey registered: {hotkey}");
|
||||
}
|
||||
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)
|
||||
{
|
||||
Logger.LogTrace("[HotkeyService] Hotkey unregistered");
|
||||
}
|
||||
else
|
||||
{
|
||||
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)
|
||||
{
|
||||
Logger.LogInfo("[HotkeyService] WM_HOTKEY received, invoking action");
|
||||
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);
|
||||
}
|
||||
}
|
||||
448
src/modules/powerdisplay/PowerDisplay/Helpers/MonitorManager.cs
Normal file
448
src/modules/powerdisplay/PowerDisplay/Helpers/MonitorManager.cs
Normal file
@@ -0,0 +1,448 @@
|
||||
// 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
|
||||
{
|
||||
/// <summary>
|
||||
/// Monitor manager for unified control of all monitors
|
||||
/// No interface abstraction - KISS principle (only one implementation needed)
|
||||
/// </summary>
|
||||
public partial class MonitorManager : IDisposable
|
||||
{
|
||||
private readonly List<Monitor> _monitors = new();
|
||||
private readonly Dictionary<string, Monitor> _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<Monitor> Monitors => _monitors.AsReadOnly();
|
||||
|
||||
public MonitorManager()
|
||||
{
|
||||
// Initialize controllers
|
||||
InitializeControllers();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initialize controllers
|
||||
/// </summary>
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Discover all monitors from all controllers.
|
||||
/// Each controller is responsible for fully initializing its monitors
|
||||
/// (including brightness, capabilities, input source, color temperature, etc.)
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<Monitor>> 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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Discover monitors from all registered controllers in parallel.
|
||||
/// </summary>
|
||||
private async Task<List<Monitor>> DiscoverFromAllControllersAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var tasks = new List<Task<IEnumerable<Monitor>>>();
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Safely discover monitors from a controller, returning empty list on failure.
|
||||
/// </summary>
|
||||
private static async Task<IEnumerable<Monitor>> 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<Monitor>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get brightness of the specified monitor
|
||||
/// </summary>
|
||||
public async Task<VcpFeatureValue> 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set brightness of the specified monitor
|
||||
/// </summary>
|
||||
public Task<MonitorOperationResult> 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);
|
||||
|
||||
/// <summary>
|
||||
/// Set contrast of the specified monitor
|
||||
/// </summary>
|
||||
public Task<MonitorOperationResult> 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);
|
||||
|
||||
/// <summary>
|
||||
/// Set volume of the specified monitor
|
||||
/// </summary>
|
||||
public Task<MonitorOperationResult> 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);
|
||||
|
||||
/// <summary>
|
||||
/// Get monitor color temperature
|
||||
/// </summary>
|
||||
public async Task<VcpFeatureValue> 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set monitor color temperature
|
||||
/// </summary>
|
||||
public Task<MonitorOperationResult> 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);
|
||||
|
||||
/// <summary>
|
||||
/// Get current input source for a monitor
|
||||
/// </summary>
|
||||
public async Task<VcpFeatureValue> 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set input source for a monitor
|
||||
/// </summary>
|
||||
public Task<MonitorOperationResult> 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);
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
/// <param name="monitorId">Monitor ID</param>
|
||||
/// <param name="orientation">Orientation: 0=normal, 1=90°, 2=180°, 3=270°</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Operation result</returns>
|
||||
public Task<MonitorOperationResult> SetRotationAsync(string monitorId, int orientation, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var monitor = GetMonitor(monitorId);
|
||||
if (monitor == null)
|
||||
{
|
||||
Logger.LogError($"[MonitorManager] SetRotation: Monitor not found: {monitorId}");
|
||||
return Task.FromResult(MonitorOperationResult.Failure("Monitor not found"));
|
||||
}
|
||||
|
||||
// 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();
|
||||
Logger.LogInfo($"[MonitorManager] SetRotation: Successfully set {monitorId} to orientation {orientation}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.LogError($"[MonitorManager] SetRotation: Failed for {monitorId}: {result.ErrorMessage}");
|
||||
}
|
||||
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get monitor by ID. Uses dictionary lookup for O(1) performance.
|
||||
/// </summary>
|
||||
public Monitor? GetMonitor(string monitorId)
|
||||
{
|
||||
return _monitorLookup.TryGetValue(monitorId, out var monitor) ? monitor : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get controller for the monitor based on CommunicationMethod.
|
||||
/// O(1) lookup - no async validation needed since controller type is determined at discovery.
|
||||
/// </summary>
|
||||
private IMonitorController? GetControllerForMonitor(Monitor monitor)
|
||||
{
|
||||
return monitor.CommunicationMethod switch
|
||||
{
|
||||
"WMI" => _wmiController,
|
||||
"DDC/CI" => _ddcController,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generic helper to execute monitor operations with common error handling.
|
||||
/// Eliminates code duplication across Set* methods.
|
||||
/// </summary>
|
||||
private async Task<MonitorOperationResult> ExecuteMonitorOperationAsync<T>(
|
||||
string monitorId,
|
||||
T value,
|
||||
Func<IMonitorController, Monitor, T, CancellationToken, Task<MonitorOperationResult>> operation,
|
||||
Action<Monitor, T> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Helper class for waiting on Windows Named Events (Awake pattern)
|
||||
/// Based on Peek.UI implementation
|
||||
/// </summary>
|
||||
public static class NativeEventWaiter
|
||||
{
|
||||
/// <summary>
|
||||
/// Wait for a Windows Event in a background thread and invoke callback on UI thread when signaled
|
||||
/// </summary>
|
||||
/// <param name="eventName">Name of the Windows Event to wait for</param>
|
||||
/// <param name="callback">Callback to invoke when event is signaled</param>
|
||||
/// <param name="cancellationToken">Token to cancel the wait loop</param>
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
using System.IO;
|
||||
|
||||
namespace PowerDisplay.Helpers
|
||||
{
|
||||
public static class SettingsDeepLink
|
||||
{
|
||||
public enum SettingsWindow
|
||||
{
|
||||
PowerDisplay,
|
||||
}
|
||||
|
||||
public static void OpenSettings(SettingsWindow window, 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
328
src/modules/powerdisplay/PowerDisplay/Helpers/TrayIconService.cs
Normal file
328
src/modules/powerdisplay/PowerDisplay/Helpers/TrayIconService.cs
Normal file
@@ -0,0 +1,328 @@
|
||||
// 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
|
||||
{
|
||||
/// <summary>
|
||||
/// Window procedure delegate for handling window messages.
|
||||
/// Uses primitive types to avoid accessibility issues with CsWin32-generated types.
|
||||
/// </summary>
|
||||
/// <param name="hwnd">Handle to the window.</param>
|
||||
/// <param name="msg">The message.</param>
|
||||
/// <param name="wParam">Additional message information.</param>
|
||||
/// <param name="lParam">Additional message.</param>
|
||||
/// <returns>The result of the message processing.</returns>
|
||||
internal delegate nint WndProcDelegate(nint hwnd, uint msg, nuint wParam, nint lParam);
|
||||
|
||||
[SuppressMessage("StyleCop.CSharp.NamingRules", "SA1310:Field names should not contain underscore", Justification = "Stylistically, window messages are WM_*")]
|
||||
[SuppressMessage("StyleCop.CSharp.NamingRules", "SA1306:Field names should begin with lower-case letter", Justification = "Stylistically, window messages are WM_*")]
|
||||
internal sealed partial class TrayIconService
|
||||
{
|
||||
private const uint MY_NOTIFY_ID = 1001;
|
||||
private const uint WM_TRAY_ICON = PInvoke.WM_USER + 1;
|
||||
|
||||
private readonly SettingsUtils _settingsUtils;
|
||||
private readonly Action _toggleWindowAction;
|
||||
private readonly Action _exitAction;
|
||||
private readonly Action _openSettingsAction;
|
||||
private readonly uint WM_TASKBAR_RESTART;
|
||||
|
||||
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
|
||||
WM_TASKBAR_RESTART = RegisterWindowMessageNative("TaskbarCreated");
|
||||
}
|
||||
|
||||
public void SetupTrayIcon(bool? showSystemTrayIcon = null)
|
||||
{
|
||||
var settings = _settingsUtils.GetSettingsOrDefault<PowerDisplaySettings>(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, GWL_WNDPROC, 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 = MY_NOTIFY_ID,
|
||||
uFlags = NOTIFY_ICON_DATA_FLAGS.NIF_MESSAGE | NOTIFY_ICON_DATA_FLAGS.NIF_ICON | NOTIFY_ICON_DATA_FLAGS.NIF_TIP,
|
||||
uCallbackMessage = WM_TRAY_ICON,
|
||||
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;
|
||||
}
|
||||
|
||||
Logger.LogInfo("[TrayIcon] Tray icon created successfully");
|
||||
}
|
||||
|
||||
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
|
||||
Logger.LogInfo("[TrayIcon] Settings menu clicked");
|
||||
_openSettingsAction?.Invoke();
|
||||
}
|
||||
else if (wParam == PInvoke.WM_USER + 2)
|
||||
{
|
||||
// Exit menu item
|
||||
Logger.LogInfo("[TrayIcon] Exit menu clicked");
|
||||
_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 WM_TASKBAR_RESTART 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:
|
||||
// WM_TASKBAR_RESTART isn't a compile-time constant, so we can't
|
||||
// use it in a case label
|
||||
if (uMsg == WM_TASKBAR_RESTART)
|
||||
{
|
||||
// Handle the case where explorer.exe restarts.
|
||||
// Even if we created it before, do it again
|
||||
Logger.LogInfo("[TrayIcon] Taskbar restarted, recreating tray icon");
|
||||
SetupTrayIcon();
|
||||
}
|
||||
else if (uMsg == WM_TRAY_ICON)
|
||||
{
|
||||
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:
|
||||
Logger.LogInfo("[TrayIcon] Left click/double click - toggling window");
|
||||
_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;
|
||||
}
|
||||
|
||||
// GWL_WNDPROC constant
|
||||
private const int GWL_WNDPROC = -4;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Provides conversion utilities for Visibility binding in x:Bind scenarios.
|
||||
/// AOT-compatible alternative to IValueConverter implementations.
|
||||
/// </summary>
|
||||
public static class VisibilityConverter
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts a boolean value to a Visibility value.
|
||||
/// </summary>
|
||||
/// <param name="value">The boolean value to convert.</param>
|
||||
/// <returns>Visibility.Visible if true, Visibility.Collapsed if false.</returns>
|
||||
public static Visibility BoolToVisibility(bool value) => value ? Visibility.Visible : Visibility.Collapsed;
|
||||
}
|
||||
}
|
||||
261
src/modules/powerdisplay/PowerDisplay/Helpers/WindowHelper.cs
Normal file
261
src/modules/powerdisplay/PowerDisplay/Helpers/WindowHelper.cs
Normal file
@@ -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);
|
||||
|
||||
/// <summary>
|
||||
/// Check if window is visible
|
||||
/// </summary>
|
||||
public static bool IsWindowVisible(nint hWnd)
|
||||
{
|
||||
return IsWindowVisibleNative(hWnd);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disable window moving and resizing functionality
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set whether window is topmost
|
||||
/// </summary>
|
||||
public static void SetWindowTopmost(nint hWnd, bool topmost)
|
||||
{
|
||||
SetWindowPos(
|
||||
hWnd,
|
||||
topmost ? HwndTopmost : HwndNotopmost,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
SwpNomove | SwpNosize);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Show or hide window
|
||||
/// </summary>
|
||||
public static void ShowWindow(nint hWnd, bool show)
|
||||
{
|
||||
ShowWindowNative(hWnd, show ? SwShow : SwHide);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hide window from taskbar
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the DPI scale factor for a window (relative to standard 96 DPI)
|
||||
/// </summary>
|
||||
/// <param name="window">WinUIEx window</param>
|
||||
/// <returns>DPI scale factor (1.0 = 100%, 1.25 = 125%, 1.5 = 150%, 2.0 = 200%)</returns>
|
||||
public static double GetDpiScale(WindowEx window)
|
||||
{
|
||||
return (float)window.GetDpiForWindow() / 96.0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert device-independent units (DIU) to physical pixels
|
||||
/// </summary>
|
||||
/// <param name="diu">Device-independent unit value</param>
|
||||
/// <param name="dpiScale">DPI scale factor</param>
|
||||
/// <returns>Physical pixel value</returns>
|
||||
public static int ScaleToPhysicalPixels(int diu, double dpiScale)
|
||||
{
|
||||
return (int)Math.Ceiling(diu * dpiScale);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Position a window at the bottom-right corner of the monitor where the mouse cursor is located.
|
||||
/// Uses WinUIEx MonitorInfo API which correctly handles all edge cases:
|
||||
/// - Multi-monitor setups
|
||||
/// - Taskbar at any position (top/bottom/left/right)
|
||||
/// - Different DPI settings
|
||||
/// </summary>
|
||||
/// <param name="window">WinUIEx window to position</param>
|
||||
/// <param name="width">Window width in device-independent units (DIU)</param>
|
||||
/// <param name="height">Window height in device-independent units (DIU)</param>
|
||||
/// <param name="rightMargin">Right margin in device-independent units (DIU)</param>
|
||||
public static void PositionWindowBottomRight(
|
||||
WindowEx window,
|
||||
int width,
|
||||
int height,
|
||||
int rightMargin = 0)
|
||||
{
|
||||
// Use WinUIEx MonitorInfo - 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the monitor where the mouse cursor is currently located.
|
||||
/// Falls back to primary monitor if cursor position cannot be determined.
|
||||
/// </summary>
|
||||
/// <param name="monitors">List of available monitors</param>
|
||||
/// <returns>MonitorInfo of the monitor containing the cursor</returns>
|
||||
private static MonitorInfo GetMonitorAtCursor(IList<MonitorInfo> 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];
|
||||
}
|
||||
}
|
||||
}
|
||||
5
src/modules/powerdisplay/PowerDisplay/NativeMethods.json
Normal file
5
src/modules/powerdisplay/PowerDisplay/NativeMethods.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://aka.ms/CsWin32.schema.json",
|
||||
"public": true,
|
||||
"allowMarshaling": false
|
||||
}
|
||||
17
src/modules/powerdisplay/PowerDisplay/NativeMethods.txt
Normal file
17
src/modules/powerdisplay/PowerDisplay/NativeMethods.txt
Normal file
@@ -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
|
||||
104
src/modules/powerdisplay/PowerDisplay/PowerDisplay.csproj
Normal file
104
src/modules/powerdisplay/PowerDisplay/PowerDisplay.csproj
Normal file
@@ -0,0 +1,104 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<!-- Look at Directory.Build.props in root for common stuff as well -->
|
||||
<Import Project="..\..\..\Common.Dotnet.CsWinRT.props" />
|
||||
<Import Project="..\..\..\Common.SelfContained.props" />
|
||||
<Import Project="..\..\..\Common.Dotnet.AotCompatibility.props" />
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<RootNamespace>PowerDisplay</RootNamespace>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<ApplicationIcon>Assets\PowerDisplay\PowerDisplay.ico</ApplicationIcon>
|
||||
<Platforms>x64;ARM64</Platforms>
|
||||
<UseWinUI>true</UseWinUI>
|
||||
<EnableMsixTooling>true</EnableMsixTooling>
|
||||
<EnablePreviewMsixTooling>true</EnablePreviewMsixTooling>
|
||||
<WindowsPackageType>None</WindowsPackageType>
|
||||
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
|
||||
<WindowsAppSDKSelfContained>true</WindowsAppSDKSelfContained>
|
||||
<OutputPath>..\..\..\..\$(Platform)\$(Configuration)\WinUI3Apps</OutputPath>
|
||||
<AssemblyName>PowerToys.PowerDisplay</AssemblyName>
|
||||
<!-- MRT from windows app sdk will search for a pri file with the same name of the module before defaulting to resources.pri -->
|
||||
<ProjectPriFileName>PowerToys.PowerDisplay.pri</ProjectPriFileName>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
<Nullable>enable</Nullable>
|
||||
<!-- Disable XAML-generated Main method, use custom Program.cs -->
|
||||
<DefineConstants>DISABLE_XAML_GENERATED_MAIN</DefineConstants>
|
||||
</PropertyGroup>
|
||||
<!-- Native AOT Configuration -->
|
||||
<PropertyGroup>
|
||||
<PublishSingleFile>false</PublishSingleFile>
|
||||
<DisableRuntimeMarshalling>false</DisableRuntimeMarshalling>
|
||||
<PublishAot>true</PublishAot>
|
||||
<TrimmerSingleWarn>false</TrimmerSingleWarn>
|
||||
<SuppressTrimAnalysisWarnings>false</SuppressTrimAnalysisWarnings>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<!-- Hide build log files from Solution Explorer -->
|
||||
<None Remove="*.log" />
|
||||
<None Remove="*.binlog" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<!-- Add WindowsDesktop.App framework reference to align Microsoft.VisualBasic.dll version
|
||||
with other projects that use UseWPF/UseWindowsForms. This does NOT enable WPF/WinForms,
|
||||
it only ensures consistent runtime DLL versions across all WinUI3Apps. -->
|
||||
<FrameworkReference Include="Microsoft.WindowsDesktop.App" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Page Remove="PowerDisplayXAML\App.xaml" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ApplicationDefinition Include="PowerDisplayXAML\App.xaml" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Folder Include="PowerDisplayXAML\" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.WindowsAppSDK" />
|
||||
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" />
|
||||
<PackageReference Include="WmiLight" />
|
||||
<PackageReference Include="WinUIEx" />
|
||||
<PackageReference Include="System.Collections.Immutable" />
|
||||
<PackageReference Include="CommunityToolkit.WinUI.Controls.SettingsControls" />
|
||||
<PackageReference Include="CommunityToolkit.WinUI.Animations" />
|
||||
<PackageReference Include="CommunityToolkit.WinUI.Extensions" />
|
||||
<PackageReference Include="CommunityToolkit.WinUI.Controls.Segmented" />
|
||||
<PackageReference Include="CommunityToolkit.WinUI.Controls.Sizers" />
|
||||
<PackageReference Include="CommunityToolkit.WinUI.Converters" />
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" />
|
||||
<PackageReference Include="Microsoft.Windows.CsWin32">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<Manifest Include="$(ApplicationManifest)" />
|
||||
</ItemGroup>
|
||||
<!--
|
||||
Defining the "Msix" ProjectCapability here allows the Single-project MSIX Packaging
|
||||
Tools extension to be activated for this project even if the Windows App SDK Nuget
|
||||
package has not yet been restored.
|
||||
-->
|
||||
<ItemGroup Condition="'$(DisableMsixProjectCapabilityAddedByProject)'!='true' and '$(EnableMsixTooling)'=='true'">
|
||||
<ProjectCapability Include="Msix" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<!-- Removed Common.UI dependency - SettingsDeepLink is now implemented locally -->
|
||||
<ProjectReference Include="..\..\..\common\ManagedCommon\ManagedCommon.csproj" />
|
||||
<!-- Removed ManagedCsWin32 - using CsWin32 directly for TrayIcon APIs -->
|
||||
<ProjectReference Include="..\..\..\common\interop\PowerToys.Interop.vcxproj" />
|
||||
<ProjectReference Include="..\..\..\settings-ui\Settings.UI.Library\Settings.UI.Library.csproj" />
|
||||
<ProjectReference Include="..\PowerDisplay.Lib\PowerDisplay.Lib.csproj" />
|
||||
</ItemGroup>
|
||||
<!-- Copy Assets folder to output directory -->
|
||||
<ItemGroup>
|
||||
<Content Include="Assets\PowerDisplay\*">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
<!--
|
||||
Defining the "HasPackageAndPublishMenuAddedByProject" property here allows the Solution
|
||||
Explorer "Package and Publish" context menu entry to be enabled for this project even if
|
||||
the Windows App SDK Nuget package has not yet been restored.
|
||||
-->
|
||||
<PropertyGroup Condition="'$(DisableHasPackageAndPublishMenuAddedByProject)'!='true' and '$(EnableMsixTooling)'=='true'">
|
||||
<HasPackageAndPublishMenu>true</HasPackageAndPublishMenu>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<Application
|
||||
x:Class="PowerDisplay.App"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:toolkit="using:CommunityToolkit.WinUI">
|
||||
<Application.Resources>
|
||||
<ResourceDictionary>
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
<!-- WinUI 3 System Resources -->
|
||||
<XamlControlsResources xmlns="using:Microsoft.UI.Xaml.Controls" />
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
</ResourceDictionary>
|
||||
</Application.Resources>
|
||||
</Application>
|
||||
@@ -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.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
|
||||
{
|
||||
/// <summary>
|
||||
/// PowerDisplay application main class
|
||||
/// </summary>
|
||||
#pragma warning disable CA1001 // CancellationTokenSource is disposed in Shutdown/ForceExit methods
|
||||
public partial class App : Application
|
||||
#pragma warning restore CA1001
|
||||
{
|
||||
private readonly SettingsUtils _settingsUtils = SettingsUtils.Default;
|
||||
private Window? _mainWindow;
|
||||
private int _powerToysRunnerPid;
|
||||
private TrayIconService? _trayIconService;
|
||||
|
||||
public App(int runnerPid)
|
||||
{
|
||||
Logger.LogInfo($"App constructor: Starting with runnerPid={runnerPid}");
|
||||
_powerToysRunnerPid = runnerPid;
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handle unhandled exceptions
|
||||
/// </summary>
|
||||
private void OnUnhandledException(object sender, Microsoft.UI.Xaml.UnhandledExceptionEventArgs e)
|
||||
{
|
||||
Logger.LogError("Unhandled exception", e.Exception);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when the application is launched
|
||||
/// </summary>
|
||||
/// <param name="args">Launch arguments</param>
|
||||
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.ApplyColorTemperaturePowerDisplayEvent(), vm => vm.ApplyColorTemperatureFromSettings(), "ApplyColorTemperature");
|
||||
RegisterViewModelEvent(Constants.ApplyProfilePowerDisplayEvent(), vm => vm.ApplyProfileFromSettings(), "ApplyProfile");
|
||||
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");
|
||||
|
||||
// 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}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Register a simple event handler (no window access needed)
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Register an event handler that operates on MainWindow directly
|
||||
/// NativeEventWaiter already marshals to UI thread
|
||||
/// </summary>
|
||||
private void RegisterWindowEvent(string eventName, Action<MainWindow> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Register an event handler that operates on ViewModel via DispatcherQueue
|
||||
/// Used for Settings UI IPC events that need ViewModel access
|
||||
/// </summary>
|
||||
private void RegisterViewModelEvent(string eventName, Action<ViewModels.MainViewModel> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the main window instance
|
||||
/// </summary>
|
||||
public Window? MainWindow => _mainWindow;
|
||||
|
||||
/// <summary>
|
||||
/// Show the main window
|
||||
/// </summary>
|
||||
private void ShowMainWindow()
|
||||
{
|
||||
Logger.LogInfo("ShowMainWindow: Called");
|
||||
if (_mainWindow is MainWindow mainWindow)
|
||||
{
|
||||
Logger.LogTrace("ShowMainWindow: MainWindow is valid, calling ShowWindow");
|
||||
mainWindow.ShowWindow();
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.LogError("ShowMainWindow: _mainWindow is null or not MainWindow type");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Toggle the main window visibility
|
||||
/// </summary>
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Open PowerDisplay settings in PowerToys Settings UI
|
||||
/// </summary>
|
||||
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(SettingsDeepLink.SettingsWindow.PowerDisplay, true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refresh tray icon based on current settings
|
||||
/// </summary>
|
||||
public void RefreshTrayIcon()
|
||||
{
|
||||
_trayIconService?.SetupTrayIcon();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if running standalone (not launched from PowerToys Runner)
|
||||
/// </summary>
|
||||
public bool IsRunningDetachedFromPowerToys()
|
||||
{
|
||||
return _powerToysRunnerPid == -1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shutdown application (Awake pattern - simple and clean)
|
||||
/// </summary>
|
||||
public void Shutdown()
|
||||
{
|
||||
Logger.LogInfo("PowerDisplay shutting down");
|
||||
_trayIconService?.Destroy();
|
||||
Environment.Exit(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<winuiex:WindowEx
|
||||
x:Class="PowerDisplay.PowerDisplayXAML.IdentifyWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:winuiex="using:WinUIEx"
|
||||
IsMaximizable="False"
|
||||
IsMinimizable="False"
|
||||
IsResizable="False"
|
||||
IsShownInSwitchers="False"
|
||||
IsTitleBarVisible="False"
|
||||
mc:Ignorable="d">
|
||||
<Grid Background="#1A000000">
|
||||
<TextBlock
|
||||
x:Name="NumberText"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
FontFamily="Segoe UI"
|
||||
FontSize="200"
|
||||
FontWeight="Bold"
|
||||
Foreground="White"
|
||||
Text="1" />
|
||||
</Grid>
|
||||
</winuiex:WindowEx>
|
||||
@@ -0,0 +1,78 @@
|
||||
// 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
|
||||
{
|
||||
/// <summary>
|
||||
/// Interaction logic for IdentifyWindow.xaml
|
||||
/// </summary>
|
||||
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()
|
||||
{
|
||||
// Get DPI scale using WinUIEx API
|
||||
_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 });
|
||||
|
||||
// Window properties (IsResizable, IsMinimizable, IsMaximizable,
|
||||
// IsTitleBarVisible, IsShownInSwitchers) are set in XAML
|
||||
|
||||
// Set window topmost using WinUIEx API
|
||||
this.IsAlwaysOnTop = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Position the window at the center of the specified display area
|
||||
/// </summary>
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
<winuiex:WindowEx
|
||||
x:Class="PowerDisplay.MainWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:animatedVisuals="using:Microsoft.UI.Xaml.Controls.AnimatedVisuals"
|
||||
xmlns:animations="using:CommunityToolkit.WinUI.Animations"
|
||||
xmlns:helpers="using:PowerDisplay.Helpers"
|
||||
xmlns:local="using:PowerDisplay"
|
||||
xmlns:models="using:PowerDisplay.Common.Models"
|
||||
xmlns:toolkit="using:CommunityToolkit.WinUI.Controls"
|
||||
xmlns:ui="using:CommunityToolkit.WinUI"
|
||||
xmlns:vm="using:PowerDisplay.ViewModels"
|
||||
xmlns:winuiex="using:WinUIEx"
|
||||
MinWidth="0"
|
||||
MinHeight="0"
|
||||
IsMaximizable="False"
|
||||
IsMinimizable="False"
|
||||
IsResizable="False"
|
||||
IsShownInSwitchers="False"
|
||||
IsTitleBarVisible="False">
|
||||
<winuiex:WindowEx.SystemBackdrop>
|
||||
<DesktopAcrylicBackdrop />
|
||||
</winuiex:WindowEx.SystemBackdrop>
|
||||
|
||||
<Grid x:Name="RootGrid" IsTabStop="True">
|
||||
<Grid.Resources>
|
||||
<Style
|
||||
x:Key="FlyoutButtonStyle"
|
||||
BasedOn="{StaticResource SubtleButtonStyle}"
|
||||
TargetType="Button">
|
||||
<Setter Property="Padding" Value="6" />
|
||||
<Setter Property="Width" Value="32" />
|
||||
<Setter Property="Height" Value="32" />
|
||||
<Setter Property="FontFamily" Value="{ThemeResource SymbolThemeFontFamily}" />
|
||||
</Style>
|
||||
</Grid.Resources>
|
||||
<Border x:Name="MainContainer">
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="48" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Main Content Area with modern design -->
|
||||
<Border
|
||||
x:Name="ContentArea"
|
||||
Background="{ThemeResource LayerOnAcrylicFillColorDefaultBrush}"
|
||||
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
|
||||
BorderThickness="0,0,0,1">
|
||||
<Grid>
|
||||
<StackPanel
|
||||
Margin="0,16,0,16"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Orientation="Vertical"
|
||||
Spacing="16"
|
||||
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ViewModel.IsScanning), Mode=OneWay}">
|
||||
<ProgressRing
|
||||
Width="24"
|
||||
Height="24"
|
||||
Foreground="{ThemeResource AccentFillColorDefaultBrush}"
|
||||
IsActive="True" />
|
||||
<TextBlock
|
||||
x:Name="ScanningMonitorsTextBlock"
|
||||
x:Uid="ScanningMonitorsText"
|
||||
HorizontalAlignment="Center"
|
||||
FontSize="12"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
TextAlignment="Center" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- No Monitors State with InfoBar -->
|
||||
<InfoBar
|
||||
x:Name="NoMonitorsInfoBar"
|
||||
x:Uid="NoMonitorsText"
|
||||
IconSource="{ui:FontIconSource Glyph=}"
|
||||
IsClosable="False"
|
||||
IsOpen="{x:Bind ViewModel.ShowNoMonitorsMessage, Mode=OneWay}"
|
||||
Severity="Informational"
|
||||
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ViewModel.ShowNoMonitorsMessage), Mode=OneWay}" />
|
||||
|
||||
<!-- Content Area -->
|
||||
<ScrollViewer
|
||||
x:Name="MainScrollViewer"
|
||||
Padding="16,16,16,16"
|
||||
VerticalAlignment="Stretch"
|
||||
HorizontalScrollBarVisibility="Disabled"
|
||||
HorizontalScrollMode="Disabled"
|
||||
VerticalScrollBarVisibility="Auto"
|
||||
ZoomMode="Disabled">
|
||||
<!-- Monitors List with modern card design -->
|
||||
<ItemsRepeater
|
||||
x:Name="MonitorsRepeater"
|
||||
HorizontalAlignment="Stretch"
|
||||
ItemsSource="{x:Bind ViewModel.Monitors, Mode=OneWay}"
|
||||
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ViewModel.HasMonitors), Mode=OneWay}">
|
||||
<ItemsRepeater.Layout>
|
||||
<StackLayout Orientation="Vertical" Spacing="32" />
|
||||
</ItemsRepeater.Layout>
|
||||
<ItemsRepeater.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:MonitorViewModel">
|
||||
<StackPanel HorizontalAlignment="Stretch" Spacing="4">
|
||||
<!-- Monitor Name with Icon -->
|
||||
<Grid Margin="2,0,0,0" HorizontalAlignment="Stretch">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="22" />
|
||||
<ColumnDefinition Width="16" />
|
||||
<!-- Spacing -->
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<local:MonitorIcon
|
||||
VerticalAlignment="Center"
|
||||
IsBuiltIn="{x:Bind IsInternal, Mode=OneWay}"
|
||||
MonitorNumber="{x:Bind MonitorNumber, Mode=OneWay}" />
|
||||
<TextBlock
|
||||
Grid.Column="2"
|
||||
Margin="0,0,0,2"
|
||||
VerticalAlignment="Center"
|
||||
Text="{x:Bind DisplayName, Mode=OneWay}" />
|
||||
<Button
|
||||
Grid.Column="2"
|
||||
HorizontalAlignment="Right"
|
||||
Content="{ui:FontIcon Glyph=,
|
||||
FontSize=16}"
|
||||
Style="{StaticResource SubtleButtonStyle}"
|
||||
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ShowInputSource), Mode=OneWay}">
|
||||
<Button.Flyout>
|
||||
<Flyout>
|
||||
<StackPanel Orientation="Vertical">
|
||||
<ListView
|
||||
ItemsSource="{x:Bind AvailableInputSources, Mode=OneWay}"
|
||||
SelectionChanged="InputSourceListView_SelectionChanged"
|
||||
SelectionMode="Single">
|
||||
<ListView.Header>
|
||||
<TextBlock
|
||||
Margin="16,0,8,0"
|
||||
FontSize="12"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Text="Input source" />
|
||||
</ListView.Header>
|
||||
<ListView.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:InputSourceItem">
|
||||
<Grid Padding="0,4">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock VerticalAlignment="Center" Text="{x:Bind Name}" />
|
||||
<FontIcon
|
||||
Grid.Column="1"
|
||||
Margin="8,0,0,0"
|
||||
FontSize="12"
|
||||
Glyph=""
|
||||
Visibility="{x:Bind SelectionVisibility}" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListView.ItemTemplate>
|
||||
</ListView>
|
||||
</StackPanel>
|
||||
</Flyout>
|
||||
</Button.Flyout>
|
||||
</Button>
|
||||
</Grid>
|
||||
<StackPanel
|
||||
Margin="0,8,0,0"
|
||||
Padding="8,0,16,8"
|
||||
Background="{ThemeResource LayerOnAcrylicFillColorDefaultBrush}"
|
||||
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="{StaticResource OverlayCornerRadius}">
|
||||
<!-- Brightness Control -->
|
||||
<Grid Margin="0,8,0,0" HorizontalAlignment="Stretch">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="16" />
|
||||
<ColumnDefinition Width="16" />
|
||||
<!-- Spacing -->
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<FontIcon
|
||||
x:Uid="BrightnessTooltip"
|
||||
VerticalAlignment="Center"
|
||||
AutomationProperties.AccessibilityView="Raw"
|
||||
FontSize="18"
|
||||
Glyph="" />
|
||||
<Slider
|
||||
x:Uid="BrightnessAutomation"
|
||||
Grid.Column="2"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Center"
|
||||
DataContext="{x:Bind}"
|
||||
IsEnabled="{x:Bind IsInteractionEnabled, Mode=OneWay}"
|
||||
Maximum="{x:Bind MaxBrightness, Mode=OneWay}"
|
||||
Minimum="{x:Bind MinBrightness, Mode=OneWay}"
|
||||
PointerCaptureLost="Slider_PointerCaptureLost"
|
||||
Tag="Brightness"
|
||||
Value="{x:Bind Brightness, Mode=OneWay}" />
|
||||
</Grid>
|
||||
|
||||
<!-- Contrast Control -->
|
||||
<Grid
|
||||
Margin="0,8,0,0"
|
||||
HorizontalAlignment="Stretch"
|
||||
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ShowContrast), Mode=OneWay}">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="16" />
|
||||
<ColumnDefinition Width="16" />
|
||||
<!-- Spacing -->
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<FontIcon
|
||||
x:Uid="ContrastTooltip"
|
||||
VerticalAlignment="Center"
|
||||
AutomationProperties.AccessibilityView="Raw"
|
||||
FontSize="12"
|
||||
Glyph="" />
|
||||
|
||||
<Slider
|
||||
x:Uid="ContrastAutomation"
|
||||
Grid.Column="2"
|
||||
VerticalAlignment="Center"
|
||||
DataContext="{x:Bind}"
|
||||
IsEnabled="{x:Bind IsInteractionEnabled, Mode=OneWay}"
|
||||
Maximum="100"
|
||||
Minimum="0"
|
||||
PointerCaptureLost="Slider_PointerCaptureLost"
|
||||
Tag="Contrast"
|
||||
Value="{x:Bind ContrastPercent, Mode=OneWay}" />
|
||||
</Grid>
|
||||
|
||||
<!-- Volume Control -->
|
||||
<Grid
|
||||
Margin="0,8,0,0"
|
||||
HorizontalAlignment="Stretch"
|
||||
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ShowVolume), Mode=OneWay}">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="16" />
|
||||
<ColumnDefinition Width="16" />
|
||||
<!-- Spacing -->
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<FontIcon
|
||||
x:Uid="VolumeTooltip"
|
||||
Margin="2,0,0,0"
|
||||
VerticalAlignment="Center"
|
||||
AutomationProperties.AccessibilityView="Raw"
|
||||
FontSize="16"
|
||||
Glyph="" />
|
||||
<Slider
|
||||
x:Uid="VolumeAutomation"
|
||||
Grid.Column="2"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Center"
|
||||
DataContext="{x:Bind}"
|
||||
IsEnabled="{x:Bind IsInteractionEnabled, Mode=OneWay}"
|
||||
Maximum="{x:Bind MaxVolume, Mode=OneWay}"
|
||||
Minimum="{x:Bind MinVolume, Mode=OneWay}"
|
||||
PointerCaptureLost="Slider_PointerCaptureLost"
|
||||
Tag="Volume"
|
||||
Value="{x:Bind Volume, Mode=OneWay}" />
|
||||
</Grid>
|
||||
|
||||
<!-- Rotation Controls -->
|
||||
<Grid
|
||||
Margin="0,8,0,0"
|
||||
HorizontalAlignment="Stretch"
|
||||
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ShowRotation), Mode=OneWay}">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="16" />
|
||||
<ColumnDefinition Width="16" />
|
||||
<!-- Spacing -->
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<FontIcon
|
||||
x:Uid="RotationTooltip"
|
||||
VerticalAlignment="Center"
|
||||
AutomationProperties.AccessibilityView="Raw"
|
||||
FontSize="16"
|
||||
Glyph="" />
|
||||
|
||||
<Grid Grid.Column="2" HorizontalAlignment="Stretch">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="4" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="4" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="4" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<!-- Normal (0°) -->
|
||||
<ToggleButton
|
||||
x:Uid="RotateNormalTooltip"
|
||||
Grid.Column="0"
|
||||
HorizontalAlignment="Stretch"
|
||||
Click="RotationButton_Click"
|
||||
DataContext="{x:Bind}"
|
||||
IsChecked="{x:Bind IsRotation0, Mode=OneWay}"
|
||||
IsEnabled="{x:Bind IsInteractionEnabled, Mode=OneWay}"
|
||||
Tag="0">
|
||||
<FontIcon FontSize="14" Glyph="" />
|
||||
</ToggleButton>
|
||||
<!-- Left (270°) -->
|
||||
<ToggleButton
|
||||
x:Uid="RotateLeftTooltip"
|
||||
Grid.Column="2"
|
||||
HorizontalAlignment="Stretch"
|
||||
Click="RotationButton_Click"
|
||||
DataContext="{x:Bind}"
|
||||
IsChecked="{x:Bind IsRotation3, Mode=OneWay}"
|
||||
IsEnabled="{x:Bind IsInteractionEnabled, Mode=OneWay}"
|
||||
Tag="3">
|
||||
<FontIcon FontSize="14" Glyph="" />
|
||||
</ToggleButton>
|
||||
<!-- Right (90°) -->
|
||||
<ToggleButton
|
||||
x:Uid="RotateRightTooltip"
|
||||
Grid.Column="4"
|
||||
HorizontalAlignment="Stretch"
|
||||
Click="RotationButton_Click"
|
||||
DataContext="{x:Bind}"
|
||||
IsChecked="{x:Bind IsRotation1, Mode=OneWay}"
|
||||
IsEnabled="{x:Bind IsInteractionEnabled, Mode=OneWay}"
|
||||
Tag="1">
|
||||
<FontIcon FontSize="14" Glyph="" />
|
||||
</ToggleButton>
|
||||
<!-- Inverted (180°) -->
|
||||
<ToggleButton
|
||||
x:Uid="RotateInvertedTooltip"
|
||||
Grid.Column="6"
|
||||
HorizontalAlignment="Stretch"
|
||||
Click="RotationButton_Click"
|
||||
DataContext="{x:Bind}"
|
||||
IsChecked="{x:Bind IsRotation2, Mode=OneWay}"
|
||||
IsEnabled="{x:Bind IsInteractionEnabled, Mode=OneWay}"
|
||||
Tag="2">
|
||||
<FontIcon FontSize="14" Glyph="" />
|
||||
</ToggleButton>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ItemsRepeater.ItemTemplate>
|
||||
</ItemsRepeater>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Grid x:Name="StatusBar" Grid.Row="1">
|
||||
<!-- Action Buttons -->
|
||||
<StackPanel
|
||||
Margin="0,0,8,8"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Center"
|
||||
Orientation="Horizontal"
|
||||
Spacing="8">
|
||||
<Button
|
||||
x:Name="ProfilesButton"
|
||||
x:Uid="ProfilesTooltip"
|
||||
Content="{ui:FontIcon Glyph=,
|
||||
FontSize=16}"
|
||||
Style="{StaticResource SubtleButtonStyle}"
|
||||
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ViewModel.HasProfiles), Mode=OneWay}">
|
||||
<Button.Flyout>
|
||||
<Flyout x:Name="ProfilesFlyout">
|
||||
<ListView
|
||||
x:Name="ProfilesListView"
|
||||
ItemsSource="{x:Bind ViewModel.Profiles, Mode=OneWay}"
|
||||
SelectionChanged="ProfileListView_SelectionChanged"
|
||||
SelectionMode="Single">
|
||||
<ListView.Header>
|
||||
<TextBlock
|
||||
Margin="16,0,8,0"
|
||||
FontSize="12"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Text="Profiles" />
|
||||
</ListView.Header>
|
||||
<ListView.ItemTemplate>
|
||||
<DataTemplate x:DataType="models:PowerDisplayProfile">
|
||||
<TextBlock Padding="0,4" Text="{x:Bind Name}" />
|
||||
</DataTemplate>
|
||||
</ListView.ItemTemplate>
|
||||
</ListView>
|
||||
</Flyout>
|
||||
</Button.Flyout>
|
||||
</Button>
|
||||
<Button
|
||||
x:Name="RefreshButton"
|
||||
x:Uid="RefreshTooltip"
|
||||
Click="OnRefreshClick"
|
||||
Content="{ui:FontIcon Glyph=,
|
||||
FontSize=16}"
|
||||
Style="{StaticResource SubtleButtonStyle}" />
|
||||
<Button
|
||||
x:Name="IdentifyButton"
|
||||
x:Uid="IdentifyTooltip"
|
||||
Command="{x:Bind ViewModel.IdentifyMonitorsCommand}"
|
||||
Content="{ui:FontIcon Glyph=,
|
||||
FontSize=16}"
|
||||
Style="{StaticResource SubtleButtonStyle}" />
|
||||
<Button
|
||||
x:Name="SettingsBtn"
|
||||
x:Uid="SettingsTooltip"
|
||||
Padding="6"
|
||||
Click="OnSettingsClick"
|
||||
Style="{StaticResource FlyoutButtonStyle}">
|
||||
<ToolTipService.ToolTip>
|
||||
<TextBlock x:Uid="SettingsTooltip" />
|
||||
</ToolTipService.ToolTip>
|
||||
<AnimatedIcon x:Name="SearchAnimatedIcon">
|
||||
<AnimatedIcon.Source>
|
||||
<animatedVisuals:AnimatedSettingsVisualSource />
|
||||
</AnimatedIcon.Source>
|
||||
<AnimatedIcon.FallbackIconSource>
|
||||
<SymbolIconSource Symbol="Setting" />
|
||||
</AnimatedIcon.FallbackIconSource>
|
||||
</AnimatedIcon>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</winuiex:WindowEx>
|
||||
@@ -0,0 +1,563 @@
|
||||
// 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 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
|
||||
{
|
||||
/// <summary>
|
||||
/// PowerDisplay main window
|
||||
/// </summary>
|
||||
[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}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Register all event handlers for window and ViewModel
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when ViewModel completes initial monitor discovery.
|
||||
/// This is the single source of truth for initialization state.
|
||||
/// </summary>
|
||||
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 (WinUIEx simplified)
|
||||
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");
|
||||
|
||||
// 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 using WinUIEx simplified API
|
||||
this.Hide();
|
||||
|
||||
Logger.LogTrace($"HideWindow: Window hidden, visibility now: {IsWindowVisible()}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if window is currently visible
|
||||
/// </summary>
|
||||
/// <returns>True if window is visible, false otherwise</returns>
|
||||
public bool IsWindowVisible()
|
||||
{
|
||||
// Use WinUIEx Visible property
|
||||
bool visible = this.Visible;
|
||||
Logger.LogTrace($"IsWindowVisible: Returning {visible}");
|
||||
return visible;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Toggle window visibility (show if hidden, hide if visible)
|
||||
/// </summary>
|
||||
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(SettingsDeepLink.SettingsWindow.PowerDisplay, true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configure window properties (synchronous, no data dependency)
|
||||
/// </summary>
|
||||
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<Windows.Graphics.RectInt32>());
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Slider PointerCaptureLost event handler - updates ViewModel when drag completes
|
||||
/// This is the WinUI3 recommended way to detect drag completion
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
// ColorTemperature case removed - now controlled via Settings UI
|
||||
case "Contrast":
|
||||
monitorVm.ContrastPercent = finalValue;
|
||||
break;
|
||||
case "Volume":
|
||||
monitorVm.Volume = finalValue;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Input source ListView selection changed handler - switches the monitor input source
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rotation button click handler - changes monitor orientation
|
||||
/// </summary>
|
||||
private async void RotationButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is not Microsoft.UI.Xaml.Controls.Primitives.ToggleButton toggleButton)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the orientation from the Tag
|
||||
if (toggleButton.Tag is not string tagStr || !int.TryParse(tagStr, out int orientation))
|
||||
{
|
||||
Logger.LogWarning("[UI] RotationButton_Click: Invalid Tag");
|
||||
return;
|
||||
}
|
||||
|
||||
var monitorVm = toggleButton.DataContext as MonitorViewModel;
|
||||
if (monitorVm == null)
|
||||
{
|
||||
Logger.LogWarning("[UI] RotationButton_Click: Could not find MonitorViewModel");
|
||||
return;
|
||||
}
|
||||
|
||||
// If clicking the current orientation, restore the checked state and do nothing
|
||||
if (monitorVm.CurrentRotation == orientation)
|
||||
{
|
||||
toggleButton.IsChecked = true;
|
||||
return;
|
||||
}
|
||||
|
||||
Logger.LogInfo($"[UI] RotationButton_Click: Setting rotation for {monitorVm.Name} to {orientation}");
|
||||
|
||||
// Set the rotation
|
||||
await monitorVm.SetRotationAsync(orientation);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Profile selection changed handler - applies the selected profile
|
||||
/// </summary>
|
||||
private void ProfileListView_SelectionChanged(object sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
if (sender is not ListView listView)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var selectedProfile = listView.SelectedItem as PowerDisplayProfile;
|
||||
if (selectedProfile == null || !selectedProfile.IsValid())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Logger.LogInfo($"[UI] ProfileListView_SelectionChanged: Applying profile '{selectedProfile.Name}'");
|
||||
|
||||
// Apply profile via ViewModel command
|
||||
if (_viewModel?.ApplyProfileCommand?.CanExecute(selectedProfile) == true)
|
||||
{
|
||||
_viewModel.ApplyProfileCommand.Execute(selectedProfile);
|
||||
}
|
||||
|
||||
// Close the flyout after selection
|
||||
ProfilesFlyout?.Hide();
|
||||
|
||||
// Clear selection to allow reselecting the same profile
|
||||
listView.SelectedItem = null;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_hotkeyService?.Dispose();
|
||||
_viewModel?.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reload hotkey settings. Call this when settings change.
|
||||
/// </summary>
|
||||
public void ReloadHotkeySettings()
|
||||
{
|
||||
_hotkeyService?.ReloadSettings();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<UserControl
|
||||
x:Class="PowerDisplay.MonitorIcon"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:local="using:PowerDisplay"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d">
|
||||
|
||||
<Grid>
|
||||
<Viewbox>
|
||||
<Grid>
|
||||
<Grid x:Name="MonitorGrid">
|
||||
<FontIcon
|
||||
x:Uid="MonitorTooltip"
|
||||
VerticalAlignment="Center"
|
||||
AutomationProperties.AccessibilityView="Raw"
|
||||
FontSize="22"
|
||||
Glyph="" />
|
||||
<TextBlock
|
||||
Margin="0,0,0,4"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
FontSize="10"
|
||||
FontWeight="SemiBold"
|
||||
Text="{x:Bind MonitorNumber, Mode=OneWay}" />
|
||||
</Grid>
|
||||
|
||||
<Grid
|
||||
x:Name="BuiltInDisplayGrid"
|
||||
Padding="0,0,0,-4"
|
||||
Visibility="Collapsed">
|
||||
<FontIcon
|
||||
x:Uid="MonitorTooltip"
|
||||
Margin="0,0,0,0"
|
||||
VerticalAlignment="Center"
|
||||
AutomationProperties.AccessibilityView="Raw"
|
||||
FontSize="22"
|
||||
Glyph="" />
|
||||
<TextBlock
|
||||
Margin="0,0,0,6"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
FontSize="10"
|
||||
FontWeight="SemiBold"
|
||||
Text="{x:Bind MonitorNumber, Mode=OneWay}" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Viewbox>
|
||||
<VisualStateManager.VisualStateGroups>
|
||||
<VisualStateGroup x:Name="CommonStates">
|
||||
<VisualState x:Name="Monitor" />
|
||||
<VisualState x:Name="BuiltIn">
|
||||
<VisualState.Setters>
|
||||
<Setter Target="BuiltInDisplayGrid.Visibility" Value="Visible" />
|
||||
<Setter Target="MonitorGrid.Visibility" Value="Collapsed" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
</VisualStateManager.VisualStateGroups>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
163
src/modules/powerdisplay/PowerDisplay/Program.cs
Normal file
163
src/modules/powerdisplay/PowerDisplay/Program.cs
Normal file
@@ -0,0 +1,163 @@
|
||||
// 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 Process Starting (Primary Instance) ===");
|
||||
Logger.LogInfo($"Main: Process ID = {Environment.ProcessId}");
|
||||
Logger.LogInfo($"Main: Command line args count = {args.Length}");
|
||||
|
||||
for (int i = 0; i < args.Length; i++)
|
||||
{
|
||||
Logger.LogInfo($"Main: args[{i}] = '{args[i]}'");
|
||||
}
|
||||
|
||||
// Register activation handler for future redirects
|
||||
keyInstance.Activated += OnActivated;
|
||||
|
||||
// Parse command line arguments: args[0] = runner_pid (Awake pattern)
|
||||
int runnerPid = -1;
|
||||
|
||||
if (args.Length >= 1)
|
||||
{
|
||||
if (int.TryParse(args[0], out int parsedPid))
|
||||
{
|
||||
runnerPid = parsedPid;
|
||||
Logger.LogInfo($"Main: Parsed runner_pid={runnerPid} from args[0]");
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.LogWarning($"Main: Failed to parse PID from args[0]: '{args[0]}'");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.LogWarning("Main: No command line args provided. Running in standalone mode.");
|
||||
}
|
||||
|
||||
Logger.LogInfo("Main: Starting application");
|
||||
Microsoft.UI.Xaml.Application.Start((p) =>
|
||||
{
|
||||
Logger.LogTrace("Main: Application.Start callback - setting up SynchronizationContext");
|
||||
var context = new DispatcherQueueSynchronizationContext(DispatcherQueue.GetForCurrentThread());
|
||||
SynchronizationContext.SetSynchronizationContext(context);
|
||||
Logger.LogTrace("Main: Creating App instance");
|
||||
_app = new App(runnerPid);
|
||||
Logger.LogTrace("Main: App instance created");
|
||||
});
|
||||
|
||||
Logger.LogInfo("Main: Application.Start returned, process ending");
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Redirect activation to existing instance (Command Palette pattern)
|
||||
/// Called BEFORE logger is initialized, so no logging here
|
||||
/// </summary>
|
||||
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 _);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when an existing instance is activated by another process
|
||||
/// </summary>
|
||||
private static void OnActivated(object? sender, AppActivationArguments args)
|
||||
{
|
||||
Logger.LogInfo("OnActivated: Received activation from another instance");
|
||||
|
||||
// Toggle the window visibility when activated by another instance
|
||||
if (_app?.MainWindow is MainWindow mainWindow)
|
||||
{
|
||||
Logger.LogInfo("OnActivated: Showing/toggling main window");
|
||||
mainWindow.DispatcherQueue.TryEnqueue(() =>
|
||||
{
|
||||
Logger.LogTrace("OnActivated: Executing ShowWindow on UI thread");
|
||||
mainWindow.ShowWindow();
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.LogWarning("OnActivated: MainWindow not available");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.PowerToys.Settings.UI.Library;
|
||||
using PowerDisplay.Common.Models;
|
||||
|
||||
#pragma warning disable SA1402 // File may only contain a single type - Related JSON serialization types grouped together
|
||||
|
||||
namespace PowerDisplay.Serialization
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[JsonSerializable(typeof(MonitorInfoData))]
|
||||
[JsonSerializable(typeof(IPCMessageAction))]
|
||||
[JsonSerializable(typeof(PowerDisplaySettings))]
|
||||
[JsonSerializable(typeof(ColorTemperatureOperation))]
|
||||
[JsonSerializable(typeof(ProfileOperation))]
|
||||
[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<string>))]
|
||||
[JsonSerializable(typeof(List<MonitorInfo>))]
|
||||
[JsonSerializable(typeof(List<VcpCodeDisplayInfo>))]
|
||||
[JsonSerializable(typeof(List<VcpValueInfo>))]
|
||||
[JsonSerializable(typeof(List<PowerDisplayProfile>))]
|
||||
[JsonSerializable(typeof(List<ProfileMonitorSetting>))]
|
||||
|
||||
[JsonSourceGenerationOptions(
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.Never)]
|
||||
internal sealed partial class AppJsonContext : JsonSerializerContext
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// IPC message wrapper for parsing action-based messages.
|
||||
/// Used in App.xaml.cs for dynamic IPC command handling.
|
||||
/// </summary>
|
||||
internal sealed class IPCMessageAction
|
||||
{
|
||||
[JsonPropertyName("action")]
|
||||
public string? Action { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Service for handling LightSwitch theme change events.
|
||||
/// Reads LightSwitch settings using the standard PowerToys settings pattern.
|
||||
/// </summary>
|
||||
public static class LightSwitchService
|
||||
{
|
||||
private const string LogPrefix = "[LightSwitch]";
|
||||
|
||||
/// <summary>
|
||||
/// Get the profile name to apply for the given theme.
|
||||
/// </summary>
|
||||
/// <param name="isLightMode">Whether the theme changed to light mode.</param>
|
||||
/// <returns>The profile name to apply, or null if no profile is configured.</returns>
|
||||
public static string? GetProfileForTheme(bool isLightMode)
|
||||
{
|
||||
try
|
||||
{
|
||||
Logger.LogInfo($"{LogPrefix} Processing theme change to {(isLightMode ? "light" : "dark")} mode");
|
||||
|
||||
var settings = SettingsUtils.Default.GetSettingsOrDefault<LightSwitchSettings>(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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user