diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 120000 index 0000000000..ff80726687 --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1 @@ +../.github/copilot-instructions.md \ No newline at end of file diff --git a/.claude/agents b/.claude/agents new file mode 120000 index 0000000000..fa084a095e --- /dev/null +++ b/.claude/agents @@ -0,0 +1 @@ +../.github/agents \ No newline at end of file diff --git a/.claude/commands b/.claude/commands new file mode 120000 index 0000000000..95a795b09e --- /dev/null +++ b/.claude/commands @@ -0,0 +1 @@ +../.github/prompts \ No newline at end of file diff --git a/.claude/rules b/.claude/rules new file mode 120000 index 0000000000..89b1ff5da7 --- /dev/null +++ b/.claude/rules @@ -0,0 +1 @@ +../.github/instructions \ No newline at end of file diff --git a/.claude/skills b/.claude/skills new file mode 120000 index 0000000000..3e73f3a383 --- /dev/null +++ b/.claude/skills @@ -0,0 +1 @@ +../.github/skills \ No newline at end of file diff --git a/.github/actions/spell-check/allow/zoomit.txt b/.github/actions/spell-check/allow/zoomit.txt new file mode 100644 index 0000000000..98f3b62ca1 --- /dev/null +++ b/.github/actions/spell-check/allow/zoomit.txt @@ -0,0 +1,63 @@ +acq +APPLYTOSUBMENUS +AUDCLNT +bitmaps +BUFFERFLAGS +centiseconds +Ctl +CTLCOLOR +CTLCOLORBTN +CTLCOLORDLG +CTLCOLOREDIT +CTLCOLORLISTBOX +CTrim +DFCS +dlg +dlu +DONTCARE +DRAWITEM +DRAWITEMSTRUCT +DWLP +EDITCONTROL +ENABLEHOOK +FDE +GETCHANNELRECT +GETCHECK +GETTHUMBRECT +GIFs +HTBOTTOMRIGHT +HTHEME +KSDATAFORMAT +LEFTNOWORDWRAP +letterbox +lld +logfont +lround +MENUINFO +mic +MMRESULT +OWNERDRAW +PBGRA +pfdc +playhead +pwfx +quantums +REFKNOWNFOLDERID +reposted +SCROLLSIZEGRIP +SETDEFID +SETRECT +SHAREMODE +SHAREVIOLATION +STREAMFLAGS +submix +tci +TEXTMETRIC +tme +TRACKMOUSEEVENT +Unadvise +WASAPI +WAVEFORMATEX +WAVEFORMATEXTENSIBLE +wil +WMU diff --git a/.github/actions/spell-check/candidate.patterns b/.github/actions/spell-check/candidate.patterns index 7aa9c89c91..d530c32c7f 100644 --- a/.github/actions/spell-check/candidate.patterns +++ b/.github/actions/spell-check/candidate.patterns @@ -565,7 +565,7 @@ perl(?:\s+-[a-zA-Z]\w*)+ regexp?\.MustCompile\((?:`[^`]*`|".*"|'.*')\) # regex choice -\(\?:[^)]+\|[^)]+\) +# \(\?:[^)]+\|[^)]+\) # proto ^\s*(\w+)\s\g{-1} = diff --git a/.github/actions/spell-check/excludes.txt b/.github/actions/spell-check/excludes.txt index 551c248923..9e587fa284 100644 --- a/.github/actions/spell-check/excludes.txt +++ b/.github/actions/spell-check/excludes.txt @@ -101,11 +101,16 @@ ^doc/devdocs/akaLinks\.md$ ^NOTICE\.md$ ^src/common/CalculatorEngineCommon/exprtk\.hpp$ +^src/common/UnitTests-CommonUtils/ ^src/common/ManagedCommon/ColorFormatHelper\.cs$ ^src/common/notifications/BackgroundActivatorDLL/cpp\.hint$ ^src/common/sysinternals/Eula/ +^src/modules/cmdpal/Tests/Microsoft\.CommandPalette\.Extensions\.Toolkit\.UnitTests/FuzzyMatcherComparisonTests.cs$ +^src/modules/cmdpal/Tests/Microsoft\.CommandPalette\.Extensions\.Toolkit\.UnitTests/FuzzyMatcherDiacriticsTests.cs$ ^src/modules/cmdpal/doc/initial-sdk-spec/list-elements-mock-002\.pdn$ ^src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleMarkdownImagesPage\.cs$ +^src/modules/cmdpal/Microsoft\.CmdPal\.UI/Settings/InternalPage\.SampleData\.cs$ +^src/modules/cmdpal/Tests/Microsoft\.CmdPal\.Core\.Common\.UnitTests/.*\.TestData\.cs$ ^src/modules/colorPicker/ColorPickerUI/Shaders/GridShader\.cso$ ^src/modules/launcher/Plugins/Microsoft\.PowerToys\.Run\.Plugin\.TimeDate/Properties/ ^src/modules/MouseUtils/MouseJumpUI/MainForm\.resx$ diff --git a/.github/actions/spell-check/expect.txt b/.github/actions/spell-check/expect.txt index 79bf8cfcea..a43e81d077 100644 --- a/.github/actions/spell-check/expect.txt +++ b/.github/actions/spell-check/expect.txt @@ -11,6 +11,7 @@ ACCESSDENIED ACCESSTOKEN acfs ACIE +ACR AClient AColumn acrt @@ -22,7 +23,6 @@ ADate ADDSTRING ADDUNDORECORD ADifferent -adjacents ADMINS adml admx @@ -45,6 +45,7 @@ ALLCHILDREN ALLINPUT Allman Allmodule +ALLNOISE ALLOWUNDO ALLVIEW ALPHATYPE @@ -58,7 +59,6 @@ AOC aocfnapldcnfbofgmbbllojgocaelgdd AOklab aot -APARTMENTTHREADED APeriod apicontract apidl @@ -96,6 +96,7 @@ asf Ashcraft AShortcut ASingle +ASUS ASSOCCHANGED ASSOCF ASSOCSTR @@ -105,6 +106,7 @@ atl ATRIOX aumid authenticode +AUO AUTOBUDDY AUTOCHECKBOX AUTOHIDE @@ -122,6 +124,10 @@ azureaiinference azureinference azureopenai backticks +Backlight +Badflags +Badmode +Badparam bbwe BCIE bck @@ -130,6 +136,7 @@ bezelled bhid BIF bigbar +BIGGERSIZEOK bigobj binlog binres @@ -194,6 +201,7 @@ Carlseibert CAtl caub CBN +Cds cch CCHDEVICENAME CCHFORMNAME @@ -213,20 +221,22 @@ checkmarks CHILDACTIVATE CHILDWINDOW CHOOSEFONT +Chunghwa CIBUILD cidl CIELCh cim CImage cla -claude CLASSDC +classguid classmethod CLASSNOTAVAILABLE +claude CLEARTYPE clickable clickonce -CLIENTEDGE +clientedge clientid clientside CLIPBOARDUPDATE @@ -238,6 +248,7 @@ CLSCTX clsids Clusion cmder +CMN CMDNOTFOUNDMODULEINTERFACE cmdpal CMIC @@ -261,7 +272,6 @@ colorhistory colorhistorylimit COLORKEY colorref -Convs comctl comdlg comexp @@ -282,6 +292,7 @@ CONTEXTHELP CONTEXTMENUHANDLER contractversion CONTROLPARENT +Convs copiedcolorrepresentation coppied copyable @@ -292,6 +303,7 @@ Corpor cotaskmem COULDNOT countof +Cowait covrun cpcontrols cph @@ -310,11 +322,14 @@ CRECT CRH critsec cropandlock +crt +CROPTOSQUARE Crossdevice csdevkit CSearch CSettings cso +CSOT CSRW CStyle cswin @@ -348,18 +363,23 @@ datareader datatracker dataversion Dayof +dbcc DBID DBLCLKS DBLEPSILON DBPROP DBPROPIDSET DBPROPSET +DBT DCBA +DCapabilities DCOM DComposition DCR +ddc DDEIf Deact +debouncer debugbreak decryptor Dedup @@ -371,13 +391,13 @@ DEFAULTICON defaultlib DEFAULTONLY DEFAULTSIZE -DEFAULTTONEAREST -Defaulttonearest +defaulttonearest DEFAULTTONULL DEFAULTTOPRIMARY DEFERERASE DEFPUSHBUTTON deinitialization +DELA DELETEDKEYIMAGE DELETESCANS DEMOTYPE @@ -394,31 +414,38 @@ DESKTOPVERTRES devblogs devdocs devenv +DEVICEINTERFACE +devicetype +DEVINTERFACE devmgmt DEVMODE DEVMODEW +DEVNODES devpal +DEVTYP dfx DIALOGEX -digicert diffs +digicert DINORMAL DISABLEASACTIONKEY DISABLENOSCROLL diskmgmt DISPLAYCHANGE -DISPLAYCONFIG +displayconfig DISPLAYFLAGS DISPLAYFREQUENCY displayname DISPLAYORIENTATION +diu divyan Dlg DLGFRAME -DLGMODALFRAME +dlgmodalframe dlib dllhost dllmain +Dmdo DNLEN DONOTROUND DONTVALIDATEPATH @@ -428,6 +455,7 @@ downsampling downscale DPICHANGED DPIs +DPMS DPSAPI DQTAT DQTYPE @@ -465,15 +493,19 @@ DWMWINDOWMAXIMIZEDCHANGE DWORDLONG dworigin dwrite +Dxva dxgi eab +EAccess easeofaccess ecount -Edid +edid EDITKEYBOARD EDITSHORTCUTS EDITTEXT EFile +EInvalid +eep eku emojis ENABLEDELAYEDEXPANSION @@ -483,14 +515,15 @@ ENABLETEMPLATE encodedlaunch encryptor ENDSESSION +ENot ENSUREVISIBLE ENTERSIZEMOVE ENTRYW ENU environmentvariables -EOAC EPO epu +EProvider ERASEBKGND EREOF EResize @@ -544,7 +577,7 @@ fdx FErase fesf FFFF -FInc +FFh Figma FILEEXPLORER fileexploreraddons @@ -565,6 +598,7 @@ FILESYSPATH Filetime FILEVERSION FILTERMODE +FInc findfast findmymouse FIXEDFILEINFO @@ -586,11 +620,13 @@ formatetc FORPARSING foundrylocal FRAMECHANGED +Framechanged FRestore frm FROMTOUCH fsanitize fsmgmt +ftps fuzzingtesting fxf FZE @@ -628,6 +664,7 @@ GMEM GNumber googleai googlegemini +Gotchas gpedit gpo GPOCA @@ -645,6 +682,8 @@ gwl GWLP GWLSTYLE hangeul +Hann +Hantai Hanzi Hardlines hardlinks @@ -666,13 +705,14 @@ HCRYPTPROV hcursor hcwhite hdc +HDEVNOTIFY hdr hdrop hdwwiz Helpline helptext -HGFE hgdiobj +HGFE hglobal hhk HHmmssfff @@ -702,6 +742,7 @@ HKPD HKU HMD hmenu +HMON hmodule hmonitor homies @@ -719,6 +760,7 @@ hotkeys hotlight hotspot HPAINTBUFFER +HPhysical HRAWINPUT hredraw hres @@ -729,6 +771,7 @@ hsb HSCROLL hsi HSpeed +HSync HTCLIENT hthumbnail HTOUCHINPUT @@ -738,6 +781,7 @@ HVal HValue Hvci hwb +HWP HWHEEL HWINEVENTHOOK hwnd @@ -748,9 +792,10 @@ HWNDPARENT HWNDPREV hyjiacan IAI +icf ICONERROR ICONLOCATION -icf +ICONONLY IDCANCEL IDD idk @@ -794,6 +839,7 @@ INITTOLOGFONTSTRUCT INLINEPREFIX inlines Inno +Innolux INPC inproc INPUTHARDWARE @@ -835,19 +881,21 @@ istep ith ITHUMBNAIL IUI +IVO IUWP IWIC jeli jfif jgeosdfsdsgmkedfgdfgdfgbkmhcgcflmi jjw -JOBOBJECT jobject +JOBOBJECT jpe jpnime Jsons jsonval jxr +Kantai keybd KEYBDDATA KEYBDINPUT @@ -869,6 +917,7 @@ KILLFOCUS killrunner kmph kvp +KVM Kybd LARGEICON lastcodeanalysissucceeded @@ -884,11 +933,15 @@ Lclean Ldone Ldr LEFTALIGN +leftclick LEFTSCROLLBAR LEFTTEXT LError LEVELID LExit +Lenovo +LGD +LFU lhwnd LIBFUZZER LIBID @@ -928,9 +981,9 @@ LOWORD lparam LPBITMAPINFOHEADER LPCFHOOKPROC +lpch LPCITEMIDLIST LPCLSID -lpch lpcmi LPCMINVOKECOMMANDINFO LPCREATESTRUCT @@ -946,6 +999,7 @@ LPMONITORINFO LPOSVERSIONINFOEXW LPQUERY lprc +LPrivate LPSAFEARRAY lpstr lpsz @@ -955,7 +1009,6 @@ lptpm LPTR LPTSTR lpv -LPrivate LPW lpwcx lpwndpl @@ -993,24 +1046,27 @@ MAPTOSAMESHORTCUT MAPVK MARKDOWNPREVIEWHANDLERCPP MAXIMIZEBOX +Maximizebox MAXSHORTCUTSIZE maxversiontested mber MBM MBR Mbuttondown +mcp MDICHILD MDL mdtext mdtxt mdwn +mccs meme -mcp memicmp MENUITEMINFO MENUITEMINFOW MERGECOPY MERGEPAINT +Metacharacter metadatamatters Metadatas metafile @@ -1025,6 +1081,7 @@ mikeclayton mindaro Minimizable MINIMIZEBOX +Minimizebox MINIMIZEEND MINIMIZESTART MINMAXINFO @@ -1040,8 +1097,8 @@ mmi mmsys mobileredirect mockapi -modelcontextprotocol MODALFRAME +modelcontextprotocol MODESPRUNED MONITORENUMPROC MONITORINFO @@ -1060,7 +1117,8 @@ mouseutils MOVESIZEEND MOVESIZESTART MRM -MRT +Mrt +mrt mru MSAL msc @@ -1085,9 +1143,10 @@ MSLLHOOKSTRUCT Mso msrc msstore +mstsc +mswhql msvcp MT -mstsc MTND MULTIPLEUSE multizone @@ -1097,12 +1156,13 @@ muxxc muxxh MVPs mvvm -myorg -myrepo MVVMTK MWBEx MYICON +myorg +myrepo NAMECHANGE +Nanjing namespaceanddescendants nao NCACTIVATE @@ -1171,6 +1231,7 @@ NOMCX NOMINMAX NOMIRRORBITMAP NOMOVE +Nomove NONANTIALIASED nonclient NONCLIENTMETRICSW @@ -1192,6 +1253,7 @@ NORMALUSER NOSEARCH NOSENDCHANGING NOSIZE +Nosize NOTHOUSANDS NOTICKS NOTIFICATIONSDLL @@ -1199,9 +1261,11 @@ NOTIFYICONDATA NOTIFYICONDATAW NOTIMPL NOTOPMOST +Notopmost NOTRACK NOTSRCCOPY NOTSRCERASE +Notupdated notwindows NOTXORPEN nowarn @@ -1242,11 +1306,10 @@ opencode OPENFILENAME openrdp opensource -openxmlformats -ollama -onnx openurl +openxmlformats OPTIMIZEFORINVOKE +Optronics ORPHANEDDIALOGTITLE ORSCANS oss @@ -1282,6 +1345,7 @@ PATINVERT PATPAINT pbc pbi +PBP PBlob pbrush pcb @@ -1296,6 +1360,7 @@ PDBs PDEVMODE pdisp PDLL +pdmodels pdo pdto pdtobj @@ -1318,12 +1383,13 @@ pguid phbm phbmp phicon +PHL Photoshop phwnd pici pidl PIDLIST -PII +pii pinfo pinvoke pipename @@ -1350,6 +1416,8 @@ Popups POPUPWINDOW POSITIONITEM POWERBROADCAST +powerdisplay +POWERDISPLAYMODULEINTERFACE POWERRENAMECONTEXTMENU powerrenameinput POWERRENAMETEST @@ -1404,6 +1472,7 @@ projectname PROPERTYKEY Propset PROPVARIANT +prot PRTL prvpane psapi @@ -1431,12 +1500,16 @@ PTOKEN PToy ptstr pui +pvct PWAs pwcs PWSTR pwsz pwtd +Qdc QDC +qdc +QDS qit QITAB QITABENT @@ -1462,7 +1535,6 @@ rbhid Rbuttondown rclsid RCZOOMIT -remotedesktop rdp RDW READMODE @@ -1481,7 +1553,9 @@ regfile REGISTERCLASSFAILED REGISTRYHEADER REGISTRYPREVIEWEXT +registryroot regkey +regroot regsvr REINSTALLMODE releaseblog @@ -1491,6 +1565,7 @@ remappings REMAPSUCCESSFUL REMAPUNSUCCESSFUL Remotable +remotedesktop remoteip Removelnk renamable @@ -1524,8 +1599,8 @@ RIGHTSCROLLBAR riid RKey RNumber -rop rollups +rop ROUNDSMALL ROWSETEXT rpcrt @@ -1657,6 +1732,7 @@ sigdn Signedness SIGNINGSCENARIO signtool +SIIGBF SINGLEKEY sipolicy SIZEBOX @@ -1709,6 +1785,7 @@ srw srwlock sse ssf +Ssn sszzz STACKFRAME stackoverflow @@ -1720,6 +1797,7 @@ STARTUPINFOW startupscreen STATFLAG STATICEDGE +Staticedge staticmethod STATSTG stdafx @@ -1756,6 +1834,7 @@ subkeys sublang SUBMODULEUPDATE subresource +swp Superbar sut svchost @@ -1784,8 +1863,7 @@ SYSKEY syskeydown SYSKEYUP SYSLIB -SYSMENU -Sysmenu +sysmenu systemai SYSTEMAPPS SYSTEMMODAL @@ -1820,12 +1898,15 @@ TEXTBOXNEWLINE textextractor TEXTINCLUDE tfopen +tgamma tgz THEMECHANGED themeresources THH THICKFRAME +Thickframe THISCOMPONENT +Tianma throughs TILEDWINDOW TILLSON @@ -1889,9 +1970,9 @@ uitests UITo ULONGLONG Ultrawide -ums UMax UMin +ums uncompilable UNCPRIORITY UNDNAME @@ -1906,13 +1987,13 @@ UNLEN UNORM unremapped Unsubscribes +unsubscribes unvirtualized unwide unzoom UOffset UOI UPDATENOW -UPDATEREGISTRY updown UPGRADINGPRODUCTCODE upscaling @@ -1939,6 +2020,8 @@ vcamp vcenter vcgtq VCINSTALLDIR +vcp +vcpname Vcpkg VCRT vcruntime @@ -1951,6 +2034,8 @@ VERIFYCONTEXT VERSIONINFO VERTRES VERTSIZE +VESA +vesa VFT vget vgetq @@ -1982,6 +2067,7 @@ VSM vso vsonline VSpeed +VSync vstemplate vstest VSTHRD @@ -2023,7 +2109,7 @@ winapi winappsdk windir WINDOWCREATED -WINDOWEDGE +windowedge WINDOWINFO WINDOWNAME WINDOWPLACEMENT @@ -2047,12 +2133,12 @@ WINL winlogon winmd winml -WINNT winres winrt winsdk winsta WINTHRESHOLD +WINNT WINVER winxamlmanager withinrafael @@ -2064,6 +2150,7 @@ WKSG Wlkr wmain Wman +wmi WMI WMICIM wmimgmt @@ -2076,6 +2163,7 @@ WNDCLASSEX WNDCLASSEXW WNDCLASSW WNDPROC +Wndproc wnode wom WORKSPACESEDITOR diff --git a/.github/actions/spell-check/patterns.txt b/.github/actions/spell-check/patterns.txt index 024cef81a1..34b2ad9fe9 100644 --- a/.github/actions/spell-check/patterns.txt +++ b/.github/actions/spell-check/patterns.txt @@ -274,5 +274,18 @@ St&yle # 0x6f677548 is user name but user folder causes a flag \bx6f677548\b +# Windows API constants and hardware interface terms +\bCOINIT[_A-Z]*\b +\bEOAC[_A-Z]*\b +\b(?:RPC_C_AUTHN_)?WINNT\b +\bUPDATEREGISTRY\b +\b(?:CDS_)?UPDATEREGISTRY\b + +# Display interface terms (HDMI, DVI, DisplayPort) +\b(?:HDMI|DVI|DisplayPort)(?:-\d+)?\b + +# 2D Region struct names +\bDisplayConfig2?D?Region\b + # Microsoft Store URLs and product IDs ms-windows-store://\S+ diff --git a/.github/prompts/create-commit-title.prompt.md b/.github/prompts/create-commit-title.prompt.md index 3696fed262..f61285c304 100644 --- a/.github/prompts/create-commit-title.prompt.md +++ b/.github/prompts/create-commit-title.prompt.md @@ -1,6 +1,5 @@ --- agent: 'agent' -model: 'GPT-5.1-Codex-Max' description: 'Generate an 80-character git commit title for the local diff' --- diff --git a/.github/prompts/create-pr-summary.prompt.md b/.github/prompts/create-pr-summary.prompt.md index 82bb0c869e..9e47c2fc3c 100644 --- a/.github/prompts/create-pr-summary.prompt.md +++ b/.github/prompts/create-pr-summary.prompt.md @@ -1,6 +1,5 @@ --- agent: 'agent' -model: 'GPT-5.1-Codex-Max' description: 'Generate a PowerToys-ready pull request description from the local diff' --- diff --git a/.github/prompts/fix-issue.prompt.md b/.github/prompts/fix-issue.prompt.md index d7aeda0381..9b758c4e8d 100644 --- a/.github/prompts/fix-issue.prompt.md +++ b/.github/prompts/fix-issue.prompt.md @@ -1,6 +1,5 @@ --- agent: 'agent' -model: 'GPT-5.1-Codex-Max' description: 'Execute the fix for a GitHub issue using the previously generated implementation plan' --- diff --git a/.github/prompts/fix-pr-active-comments.prompt.md b/.github/prompts/fix-pr-active-comments.prompt.md new file mode 100644 index 0000000000..4d7c67d986 --- /dev/null +++ b/.github/prompts/fix-pr-active-comments.prompt.md @@ -0,0 +1,70 @@ +--- +description: 'Fix active pull request comments with scoped changes' +name: 'fix-pr-active-comments' +agent: 'agent' +argument-hint: 'PR number or active PR URL' +--- + +# Fix Active PR Comments + +## Mission +Resolve active pull request comments by applying only simple fixes. For complex refactors, write a plan instead of changing code. + +## Scope & Preconditions +- You must have an active pull request context or a provided PR number. +- Only implement simple changes. Do not implement large refactors. +- If required context is missing, request it and stop. + +## Inputs +- Required: ${input:pr_number:PR number or URL} +- Optional: ${input:comment_scope:files or areas to focus on} +- Optional: ${input:fixing_guidelines:additional fixing guidelines from the user} + +## Workflow +1. Locate all active (unresolved) PR review comments for the given PR. +2. For each comment, classify the change scope: + - Simple change: limited edits, localized fix, low risk, no broad redesign. + - Large refactor: multi-file redesign, architecture change, or risky behavior change. +3. For each large refactor request: + - Do not modify code. + - Write a planning document to Generated Files/prReview/${input:pr_number}/fixPlan/. +4. For each simple change request: + - Implement the fix with minimal edits. + - Run quick checks if needed. + - Commit and push the change. +5. For comments that seem invalid, unclear, or not applicable (even if simple): + - Do not change code. + - Add the item to a summary table in Generated Files/prReview/${input:pr_number}/fixPlan/overview.md. + - Consult back to the end user in a friendly, polite tone. +6. Respond to each comment that you fixed: + - Reply in the active conversation. + - Use a polite or friendly tone. + - Keep the response under 200 words. + - Resolve the comment after replying. + +## Output Expectations +- Simple fixes: code changes committed and pushed. +- Large refactors: a plan file saved to Generated Files/prReview/${input:pr_number}/fixPlan/. +- Invalid or unclear comments: captured in Generated Files/prReview/${input:pr_number}/fixPlan/overview.md. +- Each fixed comment has a reply under 200 words and is resolved. + +## Plan File Template +Use this template for each large refactor item: + +# Fix Plan: + +## Context +- Comment link: +- Impacted areas: + +## Overview Table Template +Use this table in Generated Files/prReview/${input:pr_number}/fixPlan/overview.md: + +| Comment link | Summary | Reason not applied | Suggested follow-up | +| --- | --- | --- | --- | +| | | | | + +## Quality Assurance +- Verify plan file path exists. +- Ensure no code changes were made for large refactor items. +- Confirm replies are under 200 words and comments are resolved. diff --git a/.github/prompts/fix-spelling.prompt.md b/.github/prompts/fix-spelling.prompt.md index 008fd5fae3..bd40c1feea 100644 --- a/.github/prompts/fix-spelling.prompt.md +++ b/.github/prompts/fix-spelling.prompt.md @@ -1,6 +1,5 @@ --- agent: 'agent' -model: 'GPT-5.1-Codex-Max' description: 'Resolve Code scanning / check-spelling comments on the active PR' --- diff --git a/.github/prompts/review-issue.prompt.md b/.github/prompts/review-issue.prompt.md index 45c6b7fcaa..2ed4b9ef1f 100644 --- a/.github/prompts/review-issue.prompt.md +++ b/.github/prompts/review-issue.prompt.md @@ -1,6 +1,5 @@ --- agent: 'agent' -model: 'GPT-5.1-Codex-Max' description: 'Review a GitHub issue, score it (0-100), and generate an implementation plan' --- diff --git a/.github/prompts/review-pr.prompt.md b/.github/prompts/review-pr.prompt.md index 3f8f07d6b0..0f72b6171d 100644 --- a/.github/prompts/review-pr.prompt.md +++ b/.github/prompts/review-pr.prompt.md @@ -1,6 +1,5 @@ --- agent: 'agent' -model: 'GPT-5.1-Codex-Max' description: 'Perform a comprehensive PR review with per-step Markdown and machine-readable outputs' --- diff --git a/.pipelines/ESRPSigning_core.json b/.pipelines/ESRPSigning_core.json index c1cd63aef0..6c51889d77 100644 --- a/.pipelines/ESRPSigning_core.json +++ b/.pipelines/ESRPSigning_core.json @@ -210,6 +210,11 @@ "PowerToys.PowerAccentModuleInterface.dll", "PowerToys.PowerAccentKeyboardService.dll", + "PowerToys.PowerDisplayModuleInterface.dll", + "WinUI3Apps\\PowerToys.PowerDisplay.dll", + "WinUI3Apps\\PowerToys.PowerDisplay.exe", + "PowerDisplay.Lib.dll", + "WinUI3Apps\\PowerToys.PowerRenameExt.dll", "WinUI3Apps\\PowerToys.PowerRename.exe", "WinUI3Apps\\PowerToys.PowerRenameContextMenu.dll", @@ -378,6 +383,8 @@ "UnitsNet.dll", "UtfUnknown.dll", "Wpf.Ui.dll", + "WmiLight.dll", + "WmiLight.Native.dll", "Shmuelie.WinRTServer.dll", "ToolGood.Words.Pinyin.dll" ], diff --git a/.pipelines/v2/oneFuzz.yml b/.pipelines/v2/oneFuzz.yml index adcce3fe5d..2556ae372d 100644 --- a/.pipelines/v2/oneFuzz.yml +++ b/.pipelines/v2/oneFuzz.yml @@ -35,7 +35,9 @@ stages: ${{ else }}: name: SHINE-OSS-L ${{ if eq(parameters.useVSPreview, true) }}: - demands: ImageOverride -equals SHINE-VS17-Preview + demands: ImageOverride -equals SHINE-VS18-Preview + ${{ else }}: + demands: ImageOverride -equals SHINE-VS18-Latest buildPlatforms: - ${{ parameters.platform }} buildConfigurations: [Release] diff --git a/.pipelines/v2/release.yml b/.pipelines/v2/release.yml index 71f80f574b..d3adc45f04 100644 --- a/.pipelines/v2/release.yml +++ b/.pipelines/v2/release.yml @@ -51,7 +51,9 @@ extends: pool: name: SHINE-INT-S ${{ if eq(parameters.useVSPreview, true) }}: - demands: ImageOverride -equals SHINE-VS17-Preview + demands: ImageOverride -equals SHINE-VS18-Preview + ${{ else }}: + demands: ImageOverride -equals SHINE-VS18-Latest os: windows sdl: tsa: @@ -74,7 +76,9 @@ extends: demands: # Our INT agents have a large disk mounted at P:\ - ${{ if eq(parameters.useVSPreview, true) }}: - - ImageOverride -equals SHINE-VS17-Preview + - ImageOverride -equals SHINE-VS18-Latest-Preview + - ${{ else }}: + - ImageOverride -equals SHINE-VS18-Latest os: windows variables: IsPipeline: 1 # The installer uses this to detect whether it should pick up localizations @@ -87,6 +91,7 @@ extends: official: true codeSign: true runTests: false + buildTests: false signingIdentity: serviceName: $(SigningServiceName) appId: $(SigningAppId) diff --git a/.pipelines/v2/templates/job-build-project.yml b/.pipelines/v2/templates/job-build-project.yml index b05e8907ed..e41bfbc0ad 100644 --- a/.pipelines/v2/templates/job-build-project.yml +++ b/.pipelines/v2/templates/job-build-project.yml @@ -253,11 +253,12 @@ jobs: displayName: Build PowerToys main project inputs: solution: 'PowerToys.slnx' - vsVersion: 17.0 + vsVersion: 18.0 msbuildArgs: >- -restore -graph /p:RestorePackagesConfig=true /p:CIBuild=true + /p:BuildTests=${{ parameters.buildTests }} /bl:$(LogOutputDirectory)\build-0-main.binlog ${{ parameters.additionalBuildOptions }} $(MSBuildCacheParameters) @@ -276,7 +277,7 @@ jobs: condition: and(succeeded(), eq(variables['BuildPlatform'], 'arm64')) inputs: solution: PowerToys.slnx - vsVersion: 17.0 + vsVersion: 18.0 msbuildArgs: >- -restore /p:Configuration=$(BuildConfiguration) @@ -338,7 +339,7 @@ jobs: displayName: Build BugReportTool inputs: solution: '**/tools/BugReportTool/BugReportTool.sln' - vsVersion: 17.0 + vsVersion: 18.0 msbuildArgs: >- -restore -graph /p:RestorePackagesConfig=true @@ -359,7 +360,7 @@ jobs: displayName: Build StylesReportTool inputs: solution: '**/tools/StylesReportTool/StylesReportTool.sln' - vsVersion: 17.0 + vsVersion: 18.0 msbuildArgs: >- -restore -graph /p:RestorePackagesConfig=true @@ -381,7 +382,7 @@ jobs: displayName: Publish ${{ project }} for Packaging inputs: solution: ${{ project }} - vsVersion: 17.0 + vsVersion: 18.0 msbuildArgs: >- /target:Publish /graph diff --git a/.pipelines/v2/templates/job-build-ui-tests.yml b/.pipelines/v2/templates/job-build-ui-tests.yml index 346248b80a..61e3b93436 100644 --- a/.pipelines/v2/templates/job-build-ui-tests.yml +++ b/.pipelines/v2/templates/job-build-ui-tests.yml @@ -82,7 +82,7 @@ jobs: displayName: Build UI Test Projects inputs: solution: '**/*UITest*.csproj' - vsVersion: 17.0 + vsVersion: 18.0 msbuildArgs: >- -restore -graph @@ -103,7 +103,7 @@ jobs: displayName: 'Build UI Test Module: ${{ module }}' inputs: solution: '**/*${{ module }}*.csproj' - vsVersion: 17.0 + vsVersion: 18.0 msbuildArgs: >- -restore -graph diff --git a/.pipelines/v2/templates/pipeline-ci-build.yml b/.pipelines/v2/templates/pipeline-ci-build.yml index 23b422196f..a56c575399 100644 --- a/.pipelines/v2/templates/pipeline-ci-build.yml +++ b/.pipelines/v2/templates/pipeline-ci-build.yml @@ -49,7 +49,9 @@ stages: ${{ else }}: name: SHINE-OSS-L ${{ if eq(parameters.useVSPreview, true) }}: - demands: ImageOverride -equals SHINE-VS17-Preview + demands: ImageOverride -equals SHINE-VS18-Preview + ${{ else }}: + demands: ImageOverride -equals SHINE-VS18-Latest buildPlatforms: - ${{ platform }} buildConfigurations: [Release] @@ -57,6 +59,7 @@ stages: enableMsBuildCaching: ${{ parameters.enableMsBuildCaching }} msBuildCacheIsReadOnly: ${{ parameters.msBuildCacheIsReadOnly }} runTests: ${{ parameters.runTests }} + buildTests: true useVSPreview: ${{ parameters.useVSPreview }} useLatestWinAppSDK: ${{ parameters.useLatestWinAppSDK }} ${{ if eq(parameters.useLatestWinAppSDK, true) }}: @@ -76,7 +79,9 @@ stages: ${{ else }}: name: SHINE-OSS-L ${{ if eq(parameters.useVSPreview, true) }}: - demands: ImageOverride -equals SHINE-VS17-Preview + demands: ImageOverride -equals SHINE-VS18-Preview + ${{ else }}: + demands: ImageOverride -equals SHINE-VS18-Latest buildConfigurations: [Release] official: false codeSign: false diff --git a/.pipelines/v2/templates/pipeline-ui-tests-full-build.yml b/.pipelines/v2/templates/pipeline-ui-tests-full-build.yml index a2373feb80..30de78a335 100644 --- a/.pipelines/v2/templates/pipeline-ui-tests-full-build.yml +++ b/.pipelines/v2/templates/pipeline-ui-tests-full-build.yml @@ -29,7 +29,9 @@ stages: ${{ else }}: name: SHINE-OSS-L ${{ if eq(parameters.useVSPreview, true) }}: - demands: ImageOverride -equals SHINE-VS17-Preview + demands: ImageOverride -equals SHINE-VS18-Preview + ${{ else }}: + demands: ImageOverride -equals SHINE-VS18-Latest buildPlatforms: - ${{ parameters.platform }} buildConfigurations: [Release] diff --git a/.pipelines/v2/templates/steps-build-installer-vnext.yml b/.pipelines/v2/templates/steps-build-installer-vnext.yml index bf467ed5d9..933f2ab7fe 100644 --- a/.pipelines/v2/templates/steps-build-installer-vnext.yml +++ b/.pipelines/v2/templates/steps-build-installer-vnext.yml @@ -36,7 +36,7 @@ steps: displayName: Build Shared Support DLLs inputs: solution: "**/installer/PowerToysSetup.slnx" - vsVersion: 17.0 + vsVersion: 18.0 msbuildArgs: >- /t:PowerToysSetupCustomActionsVNext;SilentFilesInUseBAFunction /p:RunBuildEvents=true;RestorePackagesConfig=true;CIBuild=true @@ -75,7 +75,7 @@ steps: displayName: πŸ’» Build VNext MSI inputs: solution: "**/installer/PowerToysSetup.slnx" - vsVersion: 17.0 + vsVersion: 18.0 msbuildArgs: >- -restore /t:PowerToysInstallerVNext @@ -92,7 +92,7 @@ steps: displayName: πŸ‘€ Build VNext MSI inputs: solution: "**/installer/PowerToysSetup.slnx" - vsVersion: 17.0 + vsVersion: 18.0 msbuildArgs: >- /t:PowerToysInstallerVNext /p:RunBuildEvents=false;PerUser=true;BuildProjectReferences=false;CIBuild=true @@ -143,7 +143,7 @@ steps: displayName: πŸ’» Build VNext Bootstrapper inputs: solution: "**/installer/PowerToysSetup.slnx" - vsVersion: 17.0 + vsVersion: 18.0 msbuildArgs: >- -restore /t:PowerToysBootstrapperVNext @@ -160,7 +160,7 @@ steps: displayName: πŸ‘€ Build VNext Bootstrapper inputs: solution: "**/installer/PowerToysSetup.slnx" - vsVersion: 17.0 + vsVersion: 18.0 msbuildArgs: >- /t:PowerToysBootstrapperVNext /p:PerUser=true;BuildProjectReferences=false;CIBuild=true diff --git a/.pipelines/verifyAndSetLatestVCToolsVersion.ps1 b/.pipelines/verifyAndSetLatestVCToolsVersion.ps1 index 4ebf3a3eec..7fef0fe296 100644 --- a/.pipelines/verifyAndSetLatestVCToolsVersion.ps1 +++ b/.pipelines/verifyAndSetLatestVCToolsVersion.ps1 @@ -1,9 +1,16 @@ -$VSInstances = ([xml](& 'C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe' -latest -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -include packages -format xml)) +# Build common vswhere base arguments +$vsWhereBaseArgs = @('-latest', '-requires', 'Microsoft.VisualStudio.Component.VC.Tools.x86.x64') +if ($env:VCWhereExtraVersionTarget) { + # Add version target if specified (e.g., '-version [18.0,19.0)' for VS2026) + $vsWhereBaseArgs += $env:VCWhereExtraVersionTarget.Split(' ') +} + +$VSInstances = ([xml](& 'C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe' @vsWhereBaseArgs -include packages -format xml)) $VSPackages = $VSInstances.instances.instance.packages.package $LatestVCPackage = ($VSPackages | ? { $_.id -eq "Microsoft.VisualCpp.Tools.Core" }) $LatestVCToolsVersion = $LatestVCPackage.version; -$VSRoot = (& 'C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe' -latest -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property 'resolvedInstallationPath') +$VSRoot = (& 'C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe' @vsWhereBaseArgs -property 'resolvedInstallationPath') $VCToolsRoot = Join-Path $VSRoot "VC\Tools\MSVC" # We have observed a few instances where the VC tools package version actually @@ -24,5 +31,12 @@ If ($Null -Eq (Get-Item $PackageVCToolPath -ErrorAction:Ignore)) { } Write-Output "Latest VCToolsVersion: $LatestVCToolsVersion" -Write-Output "Updating VCToolsVersion environment variable for job" -Write-Output "##vso[task.setvariable variable=VCToolsVersion]$LatestVCToolsVersion" + +# VS2026 (MSVC 14.50+) doesn't need explicit VCToolsVersion - let MSBuild auto-select +$MajorMinorVersion = [Version]::Parse($LatestVCToolsVersion) +If ($MajorMinorVersion.Major -eq 14 -and $MajorMinorVersion.Minor -ge 50) { + Write-Output "VS2026 detected (MSVC 14.50+). Skipping VCToolsVersion override to allow MSBuild auto-selection." +} Else { + Write-Output "Updating VCToolsVersion environment variable for job" + Write-Output "##vso[task.setvariable variable=VCToolsVersion]$LatestVCToolsVersion" +} diff --git a/.pipelines/verifyNoticeMdAgainstNugetPackages.ps1 b/.pipelines/verifyNoticeMdAgainstNugetPackages.ps1 index af9ab8ff6f..a5cf73e6e9 100644 --- a/.pipelines/verifyNoticeMdAgainstNugetPackages.ps1 +++ b/.pipelines/verifyNoticeMdAgainstNugetPackages.ps1 @@ -90,9 +90,15 @@ if ($noticeMatch.Success) { $currentNoticePackageList = "" } +# Test-only packages that are allowed to be in NOTICE.md but not in the build +# (e.g., when BuildTests=false, these packages won't appear in the NuGet list) +$allowedExtraPackages = @( + "- Moq" +) + if (!$noticeFile.Trim().EndsWith($returnList.Trim())) { - Write-Host -ForegroundColor Red "Notice.md does not match NuGet list." + Write-Host -ForegroundColor Yellow "Notice.md does not exactly match NuGet list. Analyzing differences..." # Show detailed differences $generatedPackages = $returnList -split "`r`n|`n" | Where-Object { $_.Trim() -ne "" } | Sort-Object @@ -105,7 +111,7 @@ if (!$noticeFile.Trim().EndsWith($returnList.Trim())) # Find packages in proj file list but not in NOTICE.md $missingFromNotice = $generatedPackages | Where-Object { $noticePackages -notcontains $_ } if ($missingFromNotice.Count -gt 0) { - Write-Host -ForegroundColor Red "MissingFromNotice:" + Write-Host -ForegroundColor Red "MissingFromNotice (ERROR - these must be added to NOTICE.md):" foreach ($pkg in $missingFromNotice) { Write-Host -ForegroundColor Red " $pkg" } @@ -114,10 +120,23 @@ if (!$noticeFile.Trim().EndsWith($returnList.Trim())) # Find packages in NOTICE.md but not in proj file list $extraInNotice = $noticePackages | Where-Object { $generatedPackages -notcontains $_ } - if ($extraInNotice.Count -gt 0) { - Write-Host -ForegroundColor Yellow "ExtraInNotice:" - foreach ($pkg in $extraInNotice) { - Write-Host -ForegroundColor Yellow " $pkg" + + # Filter out allowed extra packages (test-only dependencies) + $unexpectedExtra = $extraInNotice | Where-Object { $allowedExtraPackages -notcontains $_ } + $allowedExtra = $extraInNotice | Where-Object { $allowedExtraPackages -contains $_ } + + if ($allowedExtra.Count -gt 0) { + Write-Host -ForegroundColor Green "ExtraInNotice (OK - allowed test-only packages):" + foreach ($pkg in $allowedExtra) { + Write-Host -ForegroundColor Green " $pkg" + } + Write-Host "" + } + + if ($unexpectedExtra.Count -gt 0) { + Write-Host -ForegroundColor Red "ExtraInNotice (ERROR - unexpected packages in NOTICE.md):" + foreach ($pkg in $unexpectedExtra) { + Write-Host -ForegroundColor Red " $pkg" } Write-Host "" } @@ -127,10 +146,17 @@ if (!$noticeFile.Trim().EndsWith($returnList.Trim())) Write-Host " Proj file list has $($generatedPackages.Count) packages" Write-Host " NOTICE.md has $($noticePackages.Count) packages" Write-Host " MissingFromNotice: $($missingFromNotice.Count) packages" - Write-Host " ExtraInNotice: $($extraInNotice.Count) packages" + Write-Host " ExtraInNotice (allowed): $($allowedExtra.Count) packages" + Write-Host " ExtraInNotice (unexpected): $($unexpectedExtra.Count) packages" Write-Host "" - exit 1 + # Fail if there are missing packages OR unexpected extra packages + if ($missingFromNotice.Count -gt 0 -or $unexpectedExtra.Count -gt 0) { + Write-Host -ForegroundColor Red "FAILED: NOTICE.md mismatch detected." + exit 1 + } else { + Write-Host -ForegroundColor Green "PASSED: NOTICE.md matches (with allowed test-only packages)." + } } exit 0 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..1bb8fdee51 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,165 @@ +--- +description: 'Top-level AI contributor guidance for developing PowerToys - a collection of Windows productivity utilities' +applyTo: '**' +--- + +# PowerToys – AI Contributor Guide + +This is the top-level guidance for AI contributions to PowerToys. Keep changes atomic, follow existing patterns, and cite exact paths in PRs. + +## Overview + +PowerToys is a set of utilities for power users to tune and streamline their Windows experience. + +| Area | Location | Description | +|------|----------|-------------| +| Runner | `src/runner/` | Main executable, tray icon, module loader, hotkey management | +| Settings UI | `src/settings-ui/` | WinUI/WPF configuration app communicating via named pipes | +| Modules | `src/modules/` | Individual PowerToys utilities (each in its own subfolder) | +| Common Libraries | `src/common/` | Shared code: logging, IPC, settings, DPI, telemetry, utilities | +| Build Tools | `tools/build/` | Build scripts and automation | +| Documentation | `doc/devdocs/` | Developer documentation | +| Installer | `installer/` | WiX-based installer projects | + +For architecture details and module types, see [Architecture Overview](doc/devdocs/core/architecture.md). + +## Conventions + +For detailed coding conventions, see: +- [Coding Guidelines](doc/devdocs/development/guidelines.md) – Dependencies, testing, PR management +- [Coding Style](doc/devdocs/development/style.md) – Formatting, C++/C#/XAML style rules +- [Logging](doc/devdocs/development/logging.md) – C++ spdlog and C# Logger usage + +### Component-Specific Instructions + +These instruction files are automatically applied when working in their respective areas: +- [Runner & Settings UI](.github/instructions/runner-settings-ui.instructions.md) – IPC contracts, schema migrations +- [Common Libraries](.github/instructions/common-libraries.instructions.md) – ABI stability, shared code guidelines + +## Build + +### Prerequisites + +- Visual Studio 2022 17.4+ or Visual Studio 2026 +- Windows 10 1803+ (April 2018 Update or newer) +- Initialize submodules once: `git submodule update --init --recursive` + +### Build Commands + +| Task | Command | +|------|---------| +| First build / NuGet restore | `tools\build\build-essentials.cmd` | +| Build current folder | `tools\build\build.cmd` | +| Build with options | `build.ps1 -Platform x64 -Configuration Release` | + +### Build Discipline + +1. One terminal per operation (build β†’ test). Do not switch or open new ones mid-flow +2. After making changes, `cd` to the project folder that changed (`.csproj`/`.vcxproj`) +3. Use scripts to build: `tools/build/build.ps1` or `tools/build/build.cmd` +4. For first build or missing NuGet packages, run `build-essentials.cmd` first +5. **Exit code 0 = success; non-zero = failure** – treat this as absolute +6. On failure, read the errors log: `build...errors.log` +7. Do not start tests or launch Runner until the build succeeds + +### Build Logs + +Located next to the solution/project being built: +- `build...errors.log` – errors only (check this first) +- `build...all.log` – full log +- `build...trace.binlog` – for MSBuild Structured Log Viewer + +For complete details, see [Build Guidelines](tools/build/BUILD-GUIDELINES.md). + +## Tests + +### Test Discovery + +- Find test projects by product code prefix (e.g., `FancyZones`, `AdvancedPaste`) +- Look for sibling folders or 1-2 levels up named `*UnitTests` or `*UITests` + +### Running Tests + +1. **Build the test project first**, wait for exit code 0 +2. Run via VS Test Explorer (`Ctrl+E, T`) or `vstest.console.exe` with filters +3. **Avoid `dotnet test`** in this repo – use VS Test Explorer or vstest.console.exe + +### Test Types + +| Type | Requirements | Setup | +|------|--------------|-------| +| Unit Tests | Standard dev environment | None | +| UI Tests | WinAppDriver v1.2.1, Developer Mode | Install from [WinAppDriver releases](https://github.com/microsoft/WinAppDriver/releases/tag/v1.2.1) | +| Fuzz Tests | OneFuzz, .NET 8 | See [Fuzzing Tests](doc/devdocs/tools/fuzzingtesting.md) | + +### Test Discipline + +1. Add or adjust tests when changing behavior +2. If tests skipped, state why (e.g., comment-only change, string rename) +3. New modules handling file I/O or user input **must** implement fuzzing tests + +### Special Requirements + +- **Mouse Without Borders**: Requires 2+ physical computers (not VMs) +- **Multi-monitor utilities**: Test with 2+ monitors, different DPI settings + +For UI test setup details, see [UI Tests](doc/devdocs/development/ui-tests.md). + +## Boundaries + +### Ask for Clarification When + +- Ambiguous spec after scanning relevant docs +- Cross-module impact (shared enum/struct) is unclear +- Security, elevation, or installer changes involved +- GPO or policy handling modifications needed + +### Areas Requiring Extra Care + +| Area | Concern | Reference | +|------|---------|-----------| +| `src/common/` | ABI breaks | [Common Libraries Instructions](.github/instructions/common-libraries.instructions.md) | +| `src/runner/`, `src/settings-ui/` | IPC contracts, schema | [Runner & Settings UI Instructions](.github/instructions/runner-settings-ui.instructions.md) | +| Installer files | Release impact | Careful review required | +| Elevation/GPO logic | Security | Confirm no regression in policy handling | + +### What NOT to Do + +- Don't merge incomplete features into main (use feature branches) +- Don't break IPC/JSON contracts without updating both runner and settings-ui +- Don't add noisy logs in hot paths +- Don't introduce third-party deps without PM approval and `NOTICE.md` update + +## Validation Checklist + +Before finishing, verify: + +- [ ] Build clean with exit code 0 +- [ ] Tests updated and passing locally +- [ ] No unintended ABI breaks or schema changes +- [ ] IPC contracts consistent between runner and settings-ui +- [ ] New dependencies added to `NOTICE.md` +- [ ] PR is atomic (one logical change), with issue linked + +## Documentation Index + +### Core Architecture +- [Architecture Overview](doc/devdocs/core/architecture.md) +- [Runner](doc/devdocs/core/runner.md) +- [Settings System](doc/devdocs/core/settings/readme.md) +- [Module Interface](doc/devdocs/modules/interface.md) + +### Development +- [Coding Guidelines](doc/devdocs/development/guidelines.md) +- [Coding Style](doc/devdocs/development/style.md) +- [Logging](doc/devdocs/development/logging.md) +- [UI Tests](doc/devdocs/development/ui-tests.md) +- [Fuzzing Tests](doc/devdocs/tools/fuzzingtesting.md) + +### Build & Tools +- [Build Guidelines](tools/build/BUILD-GUIDELINES.md) +- [Tools Overview](doc/devdocs/tools/readme.md) + +### Instructions (Auto-Applied) +- [Runner & Settings UI](.github/instructions/runner-settings-ui.instructions.md) +- [Common Libraries](.github/instructions/common-libraries.instructions.md) diff --git a/Cpp.Build.props b/Cpp.Build.props index 7b988f0d6f..5acfbdee1a 100644 --- a/Cpp.Build.props +++ b/Cpp.Build.props @@ -2,6 +2,12 @@ + + + false + false + + @@ -51,7 +57,7 @@ Use pch.h Level4 - 4679;5271;%(DisableSpecificWarnings) + 4679;4706;4874;5271;%(DisableSpecificWarnings) true TurnOffAllWarnings false @@ -110,6 +116,7 @@ v143 + v145 Unicode true Spectre diff --git a/Directory.Build.props b/Directory.Build.props index e7b415cbca..99379ecefc 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -19,6 +19,39 @@ $(Platform) + + + <_ProjectName>$(MSBuildProjectName) + + <_IsSkippedTestProject Condition="$(_ProjectName.EndsWith('Test'))">true + <_IsSkippedTestProject Condition="$(_ProjectName.EndsWith('Tests'))">true + + <_IsSkippedTestProject Condition="$(_ProjectName.StartsWith('UnitTests-'))">true + <_IsSkippedTestProject Condition="$(_ProjectName.StartsWith('UITest-'))">true + + <_IsSkippedTestProject Condition="$(MSBuildProjectDirectory.Contains('\Tests\'))">true + + + + false + false + false + false + disable + + false + false + false + false + + $(Version).0 https://github.com/microsoft/PowerToys @@ -30,7 +63,9 @@ <_PropertySheetDisplayName>PowerToys.Root.Props $(MsbuildThisFileDirectory)\Cpp.Build.props - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Directory.Build.targets b/Directory.Build.targets index ab9bad297e..9efab5a9a5 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -28,4 +28,41 @@ $(NoWarn);CS8305;SA1500;CA1852 - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Directory.Packages.props b/Directory.Packages.props index 10aed0ea4a..a054616577 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -91,6 +91,7 @@ + @@ -102,6 +103,7 @@ + @@ -131,6 +133,7 @@ + diff --git a/NOTICE.md b/NOTICE.md index 4273edbb18..e1a32d6f76 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -10,6 +10,7 @@ This software incorporates material from third parties. - Installer/Runner - Measure tool - Peek +- PowerDisplay - Registry Preview ## Utility: Color Picker @@ -1519,6 +1520,35 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` +## Utility: PowerDisplay + +### Twinkle Tray + +PowerDisplay's DDC/CI implementation references techniques from Twinkle Tray. + +**Source**: https://github.com/xanderfrangos/twinkle-tray + +MIT License + +Copyright Β© 2020 Xander Frangos + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. ## NuGet Packages used by PowerToys @@ -1557,6 +1587,7 @@ SOFTWARE. - NLog.Extensions.Logging - NLog.Schema - OpenAI +- Polly.Core - ReverseMarkdown - ScipBe.Common.Office.OneNote - SharpCompress @@ -1569,5 +1600,6 @@ SOFTWARE. - UnitsNet - UTF.Unknown - WinUIEx +- WmiLight - WPF-UI - WyHash \ No newline at end of file diff --git a/PowerToys.slnx b/PowerToys.slnx index 1f2a1fdbe9..506545a754 100644 --- a/PowerToys.slnx +++ b/PowerToys.slnx @@ -55,6 +55,7 @@ + @@ -300,6 +301,10 @@ + + + + @@ -356,6 +361,10 @@ + + + + @@ -676,6 +685,23 @@ + + + + + + + + + + + + + + + + + diff --git a/README.md b/README.md index 58ffc218a9..1931682d23 100644 --- a/README.md +++ b/README.md @@ -51,19 +51,19 @@ But to get started quickly, choose one of the installation methods below: Go to the PowerToys GitHub releases, click Assets to reveal the downloads, and choose the installer that matches your architecture and install scope. For most devices, that's the x64 per-user installer. -[github-next-release-work]: https://github.com/microsoft/PowerToys/issues?q=is%3Aissue+milestone%3A%22PowerToys+0.97%22 -[github-current-release-work]: https://github.com/microsoft/PowerToys/issues?q=is%3Aissue+milestone%3A%22PowerToys+0.96%22 -[ptUserX64]: https://github.com/microsoft/PowerToys/releases/download/v0.97.0/PowerToysUserSetup-0.97.0-x64.exe -[ptUserArm64]: https://github.com/microsoft/PowerToys/releases/download/v0.97.0/PowerToysUserSetup-0.97.0-arm64.exe -[ptMachineX64]: https://github.com/microsoft/PowerToys/releases/download/v0.97.0/PowerToysSetup-0.97.0-x64.exe -[ptMachineArm64]: https://github.com/microsoft/PowerToys/releases/download/v0.97.0/PowerToysSetup-0.97.0-arm64.exe +[github-next-release-work]: https://github.com/microsoft/PowerToys/issues?q=is%3Aissue+milestone%3A%22PowerToys+0.98%22 +[github-current-release-work]: https://github.com/microsoft/PowerToys/issues?q=is%3Aissue+milestone%3A%22PowerToys+0.97%22 +[ptUserX64]: https://github.com/microsoft/PowerToys/releases/download/v0.97.1/PowerToysUserSetup-0.97.1-x64.exe +[ptUserArm64]: https://github.com/microsoft/PowerToys/releases/download/v0.97.1/PowerToysUserSetup-0.97.1-arm64.exe +[ptMachineX64]: https://github.com/microsoft/PowerToys/releases/download/v0.97.1/PowerToysSetup-0.97.1-x64.exe +[ptMachineArm64]: https://github.com/microsoft/PowerToys/releases/download/v0.97.1/PowerToysSetup-0.97.1-arm64.exe | Description | Filename | |----------------|----------| -| Per user - x64 | [PowerToysUserSetup-0.97.0-x64.exe][ptUserX64] | -| Per user - ARM64 | [PowerToysUserSetup-0.97.0-arm64.exe][ptUserArm64] | -| Machine wide - x64 | [PowerToysSetup-0.97.0-x64.exe][ptMachineX64] | -| Machine wide - ARM64 | [PowerToysSetup-0.97.0-arm64.exe][ptMachineArm64] | +| Per user - x64 | [PowerToysUserSetup-0.97.1-x64.exe][ptUserX64] | +| Per user - ARM64 | [PowerToysUserSetup-0.97.1-arm64.exe][ptUserArm64] | +| Machine wide - x64 | [PowerToysSetup-0.97.1-x64.exe][ptMachineX64] | +| Machine wide - ARM64 | [PowerToysSetup-0.97.1-arm64.exe][ptMachineArm64] | @@ -103,18 +103,38 @@ There are For the first-time setup, please run the installer as an administrator. This ensures that the Wix tool can move wix.target to the desired location and trust the certificate used to sign the MSIX packages. @@ -109,7 +109,7 @@ dotnet tool install --global wix --version 5.0.2 ##### From the command line -1. From the start menu, open a `Developer Command Prompt for VS 2022` +1. From the start menu, open a `Developer Command Prompt for VS 2022` or `Developer Command Prompt for VS` 1. Ensure `nuget.exe` is in your `%path%` 1. In the repo root, run these commands: @@ -140,7 +140,7 @@ If you prefer, you can alternatively build prerequisite projects for the install The resulting installer will be available in the `installer\PowerToysSetupVNext\x64\Release\` folder. -To build the installer from the command line, run `Developer Command Prompt for VS 2022` in admin mode and execute the following commands. The generated installer package will be located at `\installer\PowerToysSetupVNext\{platform}\Release\MachineSetup`. +To build the installer from the command line, run `Developer Command Prompt for VS 2022` or `Developer Command Prompt for VS` in admin mode and execute the following commands. The generated installer package will be located at `\installer\PowerToysSetupVNext\{platform}\Release\MachineSetup`. ``` git clean -xfd -e *exe -- .\installer\ diff --git a/doc/devdocs/development/debugging.md b/doc/devdocs/development/debugging.md index 37242cf11a..8b6d4de27e 100644 --- a/doc/devdocs/development/debugging.md +++ b/doc/devdocs/development/debugging.md @@ -15,7 +15,7 @@ Before you can start debugging PowerToys, you need to set up your development en You can build the entire solution from the command line, which is sometimes faster than building within Visual Studio: -1. Open Developer Command Prompt for VS 2022 +1. Open `Developer Command Prompt for VS 2022` or `Developer Command Prompt for VS` 2. Navigate to the repository root directory 3. Run the following command(don't forget to set the correct platform): ```pwsh @@ -105,7 +105,7 @@ If you encounter build errors about missing image files (e.g., `.png`, `.ico`, o 1. **Clean the solution in Visual Studio**: Build > Clean Solution - Or from the command line (Developer Command Prompt for VS 2022): + Or from the command line (Developer Command Prompt for VS 2022 or Developer Command Prompt for VS): ```pwsh msbuild PowerToys.slnx /t:Clean /p:Platform=x64 /p:Configuration=Debug ``` diff --git a/doc/devdocs/development/dev-with-vscode.md b/doc/devdocs/development/dev-with-vscode.md index bd2f894f79..8228e3e339 100644 --- a/doc/devdocs/development/dev-with-vscode.md +++ b/doc/devdocs/development/dev-with-vscode.md @@ -15,9 +15,11 @@ VS Code extensions Needed: --- ## Building in VS Code -### Configure developer powershell for vs2022 for more convenient dev in vscode. +### Configure Developer Powershell for VS 2022 or Developer Powershell for VS for more convenient dev in vscode. 1. Configure profile in in settings, entry: "terminal.integrated.profiles.windows" -2. Add below config as entry: +2. Add below config as entry (choose VS 2022 or VS 2026 based on your installation): + +**For Visual Studio 2022:** ```json "Developer PowerShell for VS 2022": { // Configure based on your preference @@ -27,16 +29,35 @@ VS Code extensions Needed: "-Command", "& {", "$orig = Get-Location;", - // Configure based on your environment + // Adjust path based on your edition (Community/Professional/Enterprise) "& 'C:\\Program Files\\Microsoft Visual Studio\\2022\\Enterprise\\Common7\\Tools\\Launch-VsDevShell.ps1';", "Set-Location $orig", "}" ] }, ``` -3. [Optional] Set Developer PowerShell for VS 2022 as your default profile, so that you can get a deep integration with vscode coding agent. -4. Now You can build with plain `msbuild` or configure tasks.json in below section +**For Visual Studio 2026:** +```json + "Developer PowerShell for VS": { + // Configure based on your preference + "path": "C:\\Program Files\\WindowsApps\\Microsoft.PowerShell_7.5.2.0_arm64__8wekyb3d8bbwe\\pwsh.exe", + "args": [ + "-NoExit", + "-Command", + "& {", + "$orig = Get-Location;", + // Adjust path based on your edition (Community/Professional/Enterprise) + "& 'C:\\Program Files\\Microsoft Visual Studio\\18\\Enterprise\\Common7\\Tools\\Launch-VsDevShell.ps1';", + "Set-Location $orig", + "}" + ] + }, +``` + +3. [Optional] Set your Developer PowerShell profile as the default, so that you can get a deep integration with vscode coding agent. + +4. Now you can build with plain `msbuild` or configure tasks.json in below section. Or reach out to "tools\build\BUILD-GUIDELINES.md" ### Sample plain msbuild command diff --git a/doc/devdocs/development/new-powertoy.md b/doc/devdocs/development/new-powertoy.md new file mode 100644 index 0000000000..1e0f7bccfa --- /dev/null +++ b/doc/devdocs/development/new-powertoy.md @@ -0,0 +1,311 @@ +# 🧭 Creating a new PowerToy: end-to-end developer guide + +First of all, thank you for wanting to contribute to PowerToys. The work we do would not be possible without the support of community supporters like you. + +This guide documents the process of building a new PowerToys utility from scratch, including architecture decisions, integration steps, and common pitfalls. + +--- + +## 1. Overview and prerequisites + +A PowerToy module is a self-contained utility integrated into the PowerToys ecosystem. It can be UI-based, service-based, or both. + +### Requirements + +- [Visual Studio 2026](https://visualstudio.microsoft.com/downloads/) and the following workloads/individual components: + - Desktop Development with C++ + - WinUI application development + - .NET desktop development + - Windows 10 SDK (10.0.22621.0) + - Windows 11 SDK (10.0.26100.3916) +- .NET 8 SDK +- Fork the [PowerToys repository](https://github.com/microsoft/PowerToys/tree/main) locally +- [Validate that you are able to build and run](https://github.com/microsoft/PowerToys/blob/main/doc/devdocs/development/debugging.md) `PowerToys.slnx`. + +Optional: +- [WiX v5 toolset](https://github.com/microsoft/PowerToys/tree/main) for the installer + +> [!NOTE] +> To ensure all the correct VS Workloads are installed, use [the WinGet configuration files](https://github.com/microsoft/PowerToys/tree/e13d6a78aafbcf32a4bb5f8581d041e1d057c3f1/.config) in the project repository. (Use the one that matches your VS distribution. ie: VS Community would use `configuration.winget`) + +### Folder structure + +``` +src/ + modules/ + your_module/ + YourModule.sln + YourModuleInterface/ + YourModuleUI/ (if needed) + YourModuleService/ (if needed) +``` + +--- +## 2. Design and planning + +### Decide the type of module + +Think about how your module works and which existing modules behave similarly. You are going to want to think about the UI needed for the application, the lifecycle, whether it is a service that is always running or event based. Below are some basic scenarios with some modules to explore. You can write your application in C++ or C#. +- **UI-only:** e.g., ColorPicker +- **Background service:** e.g., LightSwitch, Awake +- **Hybrid (UI + background logic):** e.g., ShortcutGuide +- **C++/C# interop:** e.g., PowerRename + +### Write your module interface + +Begin by setting up the [PowerToy module template project](https://github.com/microsoft/PowerToys/tree/main/tools/project_template). This will generate boilerplate for you to begin your new module. Below are the key headers in the Module Interface (`dllmain.cpp`) and an explanation of their purpose: +1. This is where module settings are defined. These can be anything from strings, bools, ints, and even custom Enums. +```c++ +struct ModuleSettings {}; +``` + +2. This is the header for the full class. It inherits the PowerToyModuleIface +```c++ +class ModuleInterface : public PowertoyModuleIface +{ + private: + // the private members of the class + // Can include the enabled variable, logic for event handlers, or hotkeys. + public: + // the public members of the class + // Will include the constructor and initialization logic. +} +``` + +> [!NOTE] +> Many of the class functions are boilerplate and need simple string replacements with your module name. The rest of the functions below will require bigger changes. + +3. GPO stands for "Group Policy Object" and allows for administrators to configure settings across a network of machines. It is required that your module is on this list of settings. You can right click the `powertoys_gpo` object to go to the definition and set up the `getConfiguredModuleEnabledValue` for your module. +```c++ +virtual powertoys_gpo::gpo_rule_configured_t gpo_policy_enabled_configuration() override +{ + return powertoys_gpo::getConfiguredModuleEnabledValue(); +} +``` + +4. `init_settings()` initializes the settings for the interface. Will either pull from existing settings.json or use defaults. +```c++ +void ModuleInterface::init_settings() +``` + +5. `get_config` retrieves the settings from the settings.json file. +```c++ +virtual bool get_config(wchar_t* buffer, int* buffer_size) override +``` + +6. `set_config` sets the new settings to the settings.json file. +```c++ +virtual void set_config(const wchar_t* config) override +``` + +7. `call_custom_action` allows custom actions to be called based on signals from the settings app. +```c++ +void call_custom_action(const wchar_t* action) override +``` + +8. Lifecycle events control whether the module is enabled or not, as well as the default status of the module. +```c++ +virtual void enable() // starts the module +virtual void disable() // terminates the module and performs any cleanup +virtual bool is_enabled() // returns if the module is currently enabled +virtual bool is_enabled_by_default() const override // allows the module to dictate whether it should be enabled by default in the PowerToys app. +``` + +9. Hotkey functions control the status of the hotkey. +```c++ +// takes the hotkey from settings into a format that the interface can understand +void parse_hotkey(PowerToysSettings::PowerToyValues& settings) + +// returns the hotkeys from settings +virtual size_t get_hotkeys(Hotkey* hotkeys, size_t buffer_size) override + +// performs logic when the hotkey event is fired +virtual bool on_hotkey(size_t hotkeyId) override +``` + +### Notes + +- Keep module logic isolated under `/modules/` +- Use shared utilities from [`common`](https://github.com/microsoft/PowerToys/tree/main/src/common) instead of cross-module dependencies +- init/set/get config use preset functions to access the settings. Check out the [`settings_objects.h`](https://github.com/microsoft/PowerToys/blob/main/src/common/SettingsAPI/settings_helpers.h) in `src\common\SettingsAPI` + +--- +## 3. Bootstrapping your module + +1. Use the [template](https://github.com/microsoft/PowerToys/tree/main/tools/project_template) to generate the module interface starter code. +2. Update all projects and namespaces with your module name. +3. Update GUIDs in `.vcxproj` and solution files. +4. Update the functions mentioned in the above section with your custom logic. +5. In order for your module to be detected by the runner you are required to add references to various lists. In order to register your module, add the corresponding module reference to the lists that can be found in the following files. (Hint: search other modules names to find the lists quicker) + - `src/runner/modules.h` + - `src/runner/modules.cpp` + - `src/runner/resource.h` + - `src/runner/settings_window.h` + - `src/runner/settings_window.cpp` + - `src/runner/main.cpp` + - `src/common/logger.h` (for logging) +6. ModuleInterface should build your `ModuleInterface.dll`. This will allow the runner to interact with your service. + +> [!TIP] +> Mismatched module IDs are one of the most common causes of load failures. Keep your ID consistent across manifest, registry, and service. + +--- +## 4. Write your service + +This is going to look different for every PowerToy. It may be easier to develop the application independently, and then link in the PowerToys settings logic later. But you have to write the service first, before connecting it to the runner. + +### Notes + +- This is a separate project from the Module Interface. +- You can develop this project using C# or C++. +- Set the service icon using the `.rc` file. +- Set the service name in the `.vcxproj` by setting the `` +``` + + ..\..\..\..\$(Platform)\$(Configuration)\$(MSBuildProjectName)\ + PowerToys.LightSwitchService + +``` +- To view the code of the `.vcxproj`, right click the item and select **Unload project** +- Use the following functions to interact with settings from your service +``` +ModuleSettings::instance().InitFileWatcher(); +ModuleSettings::instance().LoadSettings(); +auto& settings = ModuleSettings::instance().settings(); +``` +These come from the `ModuleSettings.h` file that lives with the Service. You can copy this from another module (e.g., Light Switch) and adjust to fit your needs. + +If your module has a user interface: +- Use the **WinUI Blank App** template when setting up your project +- Use [Windows design best practices](https://learn.microsoft.com/windows/apps/design/basics/) +- Use the [WinUI 3 Gallery](https://apps.microsoft.com/detail/9p3jfpwwdzrc) for help with your UI code, and additional guidance. + +## 5. Settings integration + +PowerToys settings are stored per-module as JSON under: + +``` +%LOCALAPPDATA%\Microsoft\PowerToys\\settings.json +``` + +### Implementation steps + +- In `src\settings-ui\Settings.UI.Library\` create `Properties.cs` and `Settings.cs` +- `Properties.cs` is where you will define your defaults. Every setting needs to be represented here. This should match what was set in the Module Interface. +- `Settings.cs`is where your settings.json will be built from. The structure should match the following +```cs +public ModuleSettings() +{ + Name = ModuleName; + Version = Assembly.GetExecutingAssembly().GetName().Version.ToString(); + Properties = new ModuleProperties(); // settings properties you set above. +} +``` + +- In `src\settings-ui\Settings.UI\ViewModels` create `ViewModel.cs` this is where the interaction happens between your settings page in the PowerToys app and the settings file that is stored on the device. Changes here will trigger the settings watcher via a `NotifyPropertyChanged` event. +- Create a `SettingsPage.xaml` at `src\settings-ui\Settings.UI\SettingsXAML\Views`. This will be the page where the user interacts with the settings of your module. +- Be sure to use resource strings for user facing strings so they can be localized. (`x:Uid` connects to Resources.resw) +```xaml +// LightSwitch.xaml + + +// Resources.resw + + Off + +``` +> [!IMPORTANT] +> In the above example we use `.Content` to target the content of the Combobox. This can change per UI element (e.g., `.Text`, `.Header`, etc.) + +> **Reminder:** Manual changes via external editors (VS Code, Notepad) do **not** trigger the settings watcher. Only changes written through PowerToys trigger reloads. + +--- + +### Gotchas: + +- Only use the WinUI 3 framework, _not_ UWP. +- Use [`DispatcherQueue`](https://learn.microsoft.com/windows/apps/develop/dispatcherqueue) when updating UI from non-UI threads. + +--- +## 6. Building and debugging + +### Debugging steps + +1. If this is your first time debugging PowerToys, be sure to follow [these steps first](https://github.com/microsoft/PowerToys/blob/main/doc/devdocs/development/debugging.md#pre-debugging-setup). +2. Set "runner" as the start up project and ensure your build configuration is set to match your system (ARM64/x64) +3. Select F5 or the **Local Windows Debugger** button to begin debugging. This should start the PowerToys runner. +4. To set breakpoints in your service, select Ctrl+Alt+P and search for your service to attach to the runner. +5. Use logs to document changes. The logs live at `%LOCALAPPDATA%\Microsoft\PowerToys\RunnerLogs` and `%LOCALAPPDATA%\Microsoft\PowerToys\Module\Service\` for the specific module. + +> [!TIP] +> PowerToys caches `.nuget` artifacts aggressively. Use `git clean -xfd` when builds behave unexpectedly. + +--- +## 7. Installer and packaging (WiX) + +### Add your module to installer + +1. Install [`WixToolset.Heat`](https://www.nuget.org/packages/WixToolset.Heat/) for Wix5 via nuget +2. Inside `installer\PowerToysInstallerVNext` add a new file for your module: `Module.wxs` +3. Inside of this file you will need copy the format from another module (ie: Light Switch) and replace the strings and GUID values. +4. The key part will be `` which is a placeholder for code that will be generated by `generateFileComponents.ps1`. +5. Inside `Product.wxs` add a line item in the `` section. It will look like a list of ` ` items. +6. Inside `generateFileComponents.ps1` you will need to add an entry to the bottom for your new module. It will follow the following format. `-fileListName Files` will match the string you set in `Module.wxs`, `` will match the name of your exe. +```bash +# Module Name +Generate-FileList -fileDepsJson "" -fileListName Files -wxsFilePath $PSScriptRoot\.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\" +Generate-FileComponents -fileListName "Files" -wxsFilePath $PSScriptRoot\.wxs -regroot $registryroot +``` +--- +## 8. Testing and validation + +### UI tests + +- Place under `/modules//Tests` +- Create a new [WinUI Unit Test App](https://learn.microsoft.com/windows/apps/winui/winui3/testing/create-winui-unit-test-project) +- Write unit tests following the format from previous modules (ie: Light Switch). This can be to test your standalone UI (if you're a module like Color Picker) or to verify that the Settings UI in the PowerToys app is controlling your service. + +### Manual validation + +- Enable/disable in PowerToys Settings +- Check initialization in logs +- Confirm icons, tooltips, and OOBE page appear correctly + +### Pro tips + +1. Validate wake/sleep and elevation states. Background modules often fail silently after resume if event handles aren’t recreated. +2. Use Windows Sandbox to simulate clean install environments +3. To simulate a "new user" you can delete the PowerToys folder from `%LOCALAPPDATA%\Microsoft` + +### Shortcut conflict detection + +If your module has a shortcut, ensure that it is properly registered following [the steps listed in the documentation](https://github.com/microsoft/PowerToys/blob/main/doc/devdocs/core/settings/settings-implementation.md#shortcut-conflict-detection) for conflict detection. + +--- +## 9. The final touches + +### Out-of-Box experience (OOBE) page + +The OOBE page is a custom settings page that gives the user at a glance information about each module. This window opens before the Settings application for new users and after updates. Create `OOBE.xaml` at `src\settings-ui\Settings.UI\SettingsXAML\OOBE\Views`. You will also need to add your module name to the enum at `src\settings-ui\Settings.UI\OOBE\Enums\PowerToysModules.cs`. + +### Module assets + +Now that your PowerToy is _done_ you can start to think about the assets that will represent your module. +- Module Icon: This will be displayed in a number of places: OOBE page, in the README, on the home screen of PowerToys, on your individual module settings page, etc. +- Module Image: This is the image you see at the top of each individual settings page. +- OOBE Image: This is the header you see on the OOBE page for each module + +> [!NOTE] +> This step is something that the Design team will handle internally to ensure consistency throughout the application. If you have ideas or recommendations on what the icon or screenshots should be for your module feel free to leave it in the "Additional Comments" section of the PR and the team will take it into consideration. + +### Documentation + +There are two types of documentation that will be required when submitting a new PowerToy: +1. Developer documentation: This will live in the [PowerToys repo](https://github.com/microsoft/PowerToys/blob/main/doc/devdocs/modules) at `/doc/devdocs/modules/` and should tell a developer how to work on your app. It should outline the module architecture, key files, testing, and tips on debugging if necessary. +2. Microsoft Learn documentation: When your new Module is ready to be merged into the PowerToys repository, an internal team member will create Microsoft Learn documentation so that users will understand how to use your module. There is not much work on your end as the developer for this step, but keep an eye on your PR in case we need more information about your PowerToy for this step. + +--- +Thank you again for contributing! If you need help, feel free to [open an issue](https://github.com/microsoft/PowerToys/issues/new/choose) and use the `Needs-Team-Response` label so we know you need attention. diff --git a/doc/devdocs/modules/advancedpaste.md b/doc/devdocs/modules/advancedpaste.md index e23fde3a8a..b2ab244432 100644 --- a/doc/devdocs/modules/advancedpaste.md +++ b/doc/devdocs/modules/advancedpaste.md @@ -18,13 +18,28 @@ Advanced Paste is a PowerToys module that provides enhanced clipboard pasting wi TODO: Add implementation details +### Paste with AI Preview + +The "Show preview" setting (`ShowCustomPreview`) controls whether AI-generated results are displayed in a preview window before pasting. **The preview feature does not consume additional AI credits**β€”the preview displays the same AI response that was already generated, cached locally from a single API call. + +The implementation flow: +1. User initiates "Paste with AI" action +2. A single AI API call is made via `ExecutePasteFormatAsync` +3. The result is cached in `GeneratedResponses` +4. If preview is enabled, the cached result is displayed in the preview UI +5. User can paste the cached result without any additional API calls + +See the `ExecutePasteFormatAsync(PasteFormat, PasteActionSource)` method in `OptionsViewModel.cs` for the implementation. + ## Debugging TODO: Add debugging information ## Settings -TODO: Add settings documentation +| Setting | Description | +|---------|-------------| +| `ShowCustomPreview` | When enabled, shows AI-generated results in a preview window before pasting. Does not affect AI credit consumption. | ## Future Improvements diff --git a/doc/devdocs/modules/fancyzones.md b/doc/devdocs/modules/fancyzones.md index a4571eacd7..0f3c812131 100644 --- a/doc/devdocs/modules/fancyzones.md +++ b/doc/devdocs/modules/fancyzones.md @@ -152,7 +152,7 @@ FancyZones is divided into several projects: ## Development Environment Setup ### Prerequisites -- Visual Studio 2022: Required for building and debugging +- Visual Studio 2022 or 2026: Required for building and debugging - Windows 10 SDK: Ensure the latest version is installed - PowerToys Repository: Clone from GitHub @@ -183,7 +183,7 @@ FancyZones is divided into several projects: ## Debugging ### Setup for Debugging -1. In Visual Studio 2022, set FancyZonesEditor as the startup project +1. In Visual Studio 2022 or 2026, set FancyZonesEditor as the startup project 2. Set breakpoints in the code where needed 3. Click Run to start debugging diff --git a/doc/devdocs/modules/powerdisplay/design.md b/doc/devdocs/modules/powerdisplay/design.md new file mode 100644 index 0000000000..ae2eb26479 --- /dev/null +++ b/doc/devdocs/modules/powerdisplay/design.md @@ -0,0 +1,1616 @@ +# PowerDisplay Module Design Document + +## Table of Contents + +1. [Background](#background) +2. [Problem Statement](#problem-statement) +3. [Goals](#goals) +4. [Technical Terminology](#technical-terminology) + - [DDC/CI (Display Data Channel Command Interface)](#ddcci-display-data-channel-command-interface) + - [WMI (Windows Management Instrumentation)](#wmi-windows-management-instrumentation) +5. [Architecture Overview](#architecture-overview) + - [High-Level Component Architecture](#high-level-component-architecture) + - [Project Structure](#project-structure) +6. [Component Design](#component-design) + - [PowerDisplay Module Internal Structure](#powerdisplay-module-internal-structure) + - [DisplayChangeWatcher - Monitor Hot-Plug Detection](#displaychangewatcher---monitor-hot-plug-detection) + - [DDC/CI and WMI Interaction Architecture](#ddcci-and-wmi-interaction-architecture) + - [IMonitorController Interface Methods](#imonitorcontroller-interface-methods) + - [Why WmiLight Instead of System.Management](#why-wmilight-instead-of-systemmanagement) + - [Why We Need an MCCS Capabilities String Parser](#why-we-need-an-mccs-capabilities-string-parser) + - [Monitor Identification: Handles, IDs, and Names](#monitor-identification-handles-ids-and-names) + - [Settings UI and PowerDisplay Interaction Architecture](#settings-ui-and-powerdisplay-interaction-architecture) + - [Windows Events for IPC](#windows-events-for-ipc) + - [LightSwitch Profile Integration Architecture](#lightswitch-profile-integration-architecture) + - [LightSwitch Settings JSON Structure](#lightswitch-settings-json-structure) +7. [Data Flow and Communication](#data-flow-and-communication) + - [Monitor Discovery Flow](#monitor-discovery-flow) +8. [Sequence Diagrams](#sequence-diagrams) + - [Sequence: Modifying Color Temperature in Flyout UI](#sequence-modifying-color-temperature-in-flyout-ui) + - [Sequence: Creating and Saving a Profile](#sequence-creating-and-saving-a-profile) + - [Sequence: Applying Profile via LightSwitch Theme Change](#sequence-applying-profile-via-lightswitch-theme-change) + - [Sequence: UI Slider Adjustment (Brightness)](#sequence-ui-slider-adjustment-brightness) + - [Sequence: Module Enable/Disable Lifecycle](#sequence-module-enabledisable-lifecycle) +9. [Future Considerations](#future-considerations) + - [Already Implemented](#already-implemented) + - [Potential Future Enhancements](#potential-future-enhancements) +10. [References](#references) + +--- + +## Background + +PowerDisplay is a PowerToys module designed to provide unified control over display +settings across multiple monitors. Users often work with multiple displays (external monitors or laptop screens) and need a +convenient way to adjust display parameters such as brightness, contrast, color +temperature, volume, and input source without navigating through individual monitor +OSD menus. + +The module leverages two primary technologies for monitor control: + +1. **DDC/CI (Display Data Channel Command Interface)** - For external monitors +2. **WMI (Windows Management Instrumentation)** - For internal(laptop) displays + +--- + +## Problem Statement + +Users with multiple monitors face several challenges: + +1. **Fragmented Control**: Each monitor requires separate OSD navigation +2. **Inconsistent Brightness**: Difficult to maintain uniform brightness across displays +3. **No Profile Support**: Cannot quickly switch display configurations for different + scenarios (gaming, productivity, movie watching) +4. **Theme Integration Gap**: No automatic display adjustment when switching between + light and dark themes + +--- + +## Goals + +- Provide unified control for brightness, contrast, volume, color temperature, and + input source across all connected monitors +- Support both DDC/CI (external monitors) and WMI (laptop displays) +- Support user-defined profiles for quick configuration switching +- Integrate with LightSwitch module for automatic profile application on theme changes +- Support global hotkey activation + +--- + +## Technical Terminology + +### DDC/CI (Display Data Channel Command Interface) + +**DDC/CI** is a VESA standard (defined in the DDC specification) that allows +bidirectional communication between a computer and a display over the I2C bus +embedded in display cables. + +Most external monitors support DDC/CI, allowing applications to read and modify settings +like brightness and contrast programmatically. But unfortunately, some manufacturers have poor implementations of their product's driver. They may not support DDC/CI or report itself supports DDC/CI (through capabilities string) when it does not. Even if a monitor supports DDC/CI, they may only support a limited subset of VCP codes, or have buggy implementations. + +And sometimes, users may connect monitor through a KVM switch or docking station that does not pass through DDC/CI commands correctly, and their docking may report it supports (hard code a capabilities string) but in reality, it does not. And will do thing when we try to send DDC/CI commands. + +PowerDisplay relies on the monitor-reported capabilities string to determine supported features. But if your monitor's manufacturer has a poor DDC/CI implementation, or you are connecting through a docking station that does not properly support DDC/CI, some features may not work as expected. And we can do nothing about it. + +**Key Concepts:** + +| Term | Description | +|------|-------------| +| **VCP (Virtual Control Panel)** | Standardized codes for monitor settings | +| **MCCS (Monitor Command Control Set)** | VESA standard defining VCP codes | +| **Capabilities String** | Monitor-reported string describing supported features | + +**Common VCP Codes Used:** + +| VCP Code | Name | Description | +|----------|------|-------------| +| `0x10` | Brightness | Display luminance (0-100) | +| `0x12` | Contrast | Display contrast ratio (0-100) | +| `0x14` | Select Color Preset | Color temperature presets (sRGB, 5000K, 6500K, etc.) | +| `0x60` | Input Source | Active video input (HDMI, DP, USB-C, etc.) | +| `0x62` | Volume | Speaker/headphone volume (0-100) | + +--- + +### WMI (Windows Management Instrumentation) + +**WMI** is Microsoft's implementation of Web-Based Enterprise Management (WBEM), +providing a standardized interface for accessing management information in Windows. +For display control, WMI is primarily used for laptop internal displays that may not +support DDC/CI. + +--- + +## Architecture Overview + +### High-Level Component Architecture + +```mermaid +flowchart TB + subgraph PowerToys["PowerToys Application"] + Runner["Runner (PowerToys.exe)"] + SettingsUI["Settings UI (WinUI 3)"] + LightSwitch["LightSwitch Module"] + end + + subgraph PowerDisplayModule["PowerDisplay Module"] + ModuleInterface["Module Interface
(PowerDisplayModuleInterface.dll)"] + PowerDisplayApp["PowerDisplay App
(PowerToys.PowerDisplay.exe)"] + PowerDisplayLib["PowerDisplay.Lib
(Shared Library)"] + end + + subgraph External["External"] + Hardware["Display Hardware
(External + Internal)"] + Storage["Persistent Storage
(settings.json, profiles.json)"] + end + + Runner -->|"Loads DLL"| ModuleInterface + Runner -->|"Hotkey Events"| ModuleInterface + SettingsUI <-->|"Named Pipes"| Runner + SettingsUI -->|"Custom Actions
(Launch, ApplyProfile)"| ModuleInterface + + ModuleInterface <-->|"Windows Events
(Show/Toggle/Terminate)"| PowerDisplayApp + PowerDisplayApp -->|"RefreshMonitors Event"| SettingsUI + LightSwitch -->|"Theme Events
(Light/Dark)"| PowerDisplayApp + + PowerDisplayApp --> PowerDisplayLib + PowerDisplayLib -->|"DDC/CI (Dxva2.dll)"| Hardware + PowerDisplayLib -->|"WMI (WmiLight)"| Hardware + PowerDisplayLib -->|"ChangeDisplaySettingsEx"| Hardware + PowerDisplayApp <--> Storage + + style Runner fill:#e1f5fe + style SettingsUI fill:#e1f5fe + style LightSwitch fill:#e1f5fe + style ModuleInterface fill:#fff3e0 + style PowerDisplayApp fill:#fff3e0 + style PowerDisplayLib fill:#e8f5e9 + style Hardware fill:#f3e5f5 + style Storage fill:#fffde7 +``` + +This high-level view shows the module boundaries. See [Component Design](#component-design) +for internal structure details. + +--- + +### Project Structure + +``` +src/modules/powerdisplay/ +β”œβ”€β”€ PowerDisplay.Lib/ # Core library (shared) +β”‚ β”œβ”€β”€ Drivers/ +β”‚ β”‚ β”œβ”€β”€ DDC/ +β”‚ β”‚ β”‚ β”œβ”€β”€ DdcCiController.cs # DDC/CI implementation +β”‚ β”‚ β”‚ β”œβ”€β”€ DdcCiNative.cs # P/Invoke declarations & QueryDisplayConfig +β”‚ β”‚ β”‚ β”œβ”€β”€ MonitorDiscoveryHelper.cs +β”‚ β”‚ β”‚ └── PhysicalMonitorHandleManager.cs +β”‚ β”‚ β”œβ”€β”€ WMI/ +β”‚ β”‚ β”‚ └── WmiController.cs # WMI implementation (WmiLight library) +β”‚ β”‚ β”œβ”€β”€ NativeConstants.cs # Win32 constants (VCP codes, etc.) +β”‚ β”‚ β”œβ”€β”€ NativeDelegates.cs # P/Invoke delegate types +β”‚ β”‚ β”œβ”€β”€ NativeStructures.cs # Win32 structures +β”‚ β”‚ └── PInvoke.cs # P/Invoke declarations +β”‚ β”œβ”€β”€ Interfaces/ +β”‚ β”‚ β”œβ”€β”€ IMonitorController.cs # Controller abstraction +β”‚ β”‚ β”œβ”€β”€ IMonitorData.cs # Monitor data interface +β”‚ β”‚ └── IProfileService.cs # Profile service interface +β”‚ β”œβ”€β”€ Models/ +β”‚ β”‚ β”œβ”€β”€ Monitor.cs # Runtime monitor data +β”‚ β”‚ β”œβ”€β”€ MonitorCapabilities.cs # Monitor capability flags +β”‚ β”‚ β”œβ”€β”€ MonitorOperationResult.cs # Operation result +β”‚ β”‚ β”œβ”€β”€ MonitorStateEntry.cs # Persisted monitor state +β”‚ β”‚ β”œβ”€β”€ MonitorStateFile.cs # State file schema +β”‚ β”‚ β”œβ”€β”€ PowerDisplayProfile.cs # Profile definition +β”‚ β”‚ β”œβ”€β”€ PowerDisplayProfiles.cs # Profile collection +β”‚ β”‚ β”œβ”€β”€ ProfileMonitorSetting.cs # Per-monitor profile settings +β”‚ β”‚ β”œβ”€β”€ ColorPresetItem.cs # Color preset UI item +β”‚ β”‚ β”œβ”€β”€ VcpCapabilities.cs # Parsed VCP capabilities +β”‚ β”‚ └── VcpFeatureValue.cs # VCP feature value (current/min/max) +β”‚ β”œβ”€β”€ Serialization/ +β”‚ β”‚ └── ProfileSerializationContext.cs # JSON source generation +β”‚ β”œβ”€β”€ Services/ +β”‚ β”‚ β”œβ”€β”€ DisplayRotationService.cs # Display rotation via ChangeDisplaySettingsEx +β”‚ β”‚ β”œβ”€β”€ MonitorStateManager.cs # State persistence (debounced save) and restore on startup +β”‚ β”‚ └── ProfileService.cs # Profile persistence +β”‚ β”œβ”€β”€ Utils/ +β”‚ β”‚ β”œβ”€β”€ ColorTemperatureHelper.cs # Color temp utilities +β”‚ β”‚ β”œβ”€β”€ EventHelper.cs # Windows Event utilities +β”‚ β”‚ β”œβ”€β”€ MccsCapabilitiesParser.cs # DDC/CI capabilities parser +β”‚ β”‚ β”œβ”€β”€ MonitorFeatureHelper.cs # Monitor feature utilities +β”‚ β”‚ β”œβ”€β”€ MonitorMatchingHelper.cs # Profile-to-monitor matching +β”‚ β”‚ β”œβ”€β”€ MonitorValueConverter.cs # Value conversion utilities +β”‚ β”‚ β”œβ”€β”€ PnpIdHelper.cs # PnP manufacturer ID lookup +β”‚ β”‚ β”œβ”€β”€ ProfileHelper.cs # Profile helper utilities +β”‚ β”‚ β”œβ”€β”€ SimpleDebouncer.cs # Generic debouncer +β”‚ β”‚ └── VcpNames.cs # VCP code and value name lookup +β”‚ └── PathConstants.cs # File path constants +β”‚ +β”œβ”€β”€ PowerDisplay/ # WinUI 3 application +β”‚ β”œβ”€β”€ Assets/ # App icons and images +β”‚ β”œβ”€β”€ Configuration/ +β”‚ β”‚ └── AppConstants.cs # Application constants +β”‚ β”œβ”€β”€ Helpers/ +β”‚ β”‚ β”œβ”€β”€ DisplayChangeWatcher.cs # Monitor hot-plug detection (WinRT DeviceWatcher) +β”‚ β”‚ β”œβ”€β”€ MonitorManager.cs # Discovery orchestrator +β”‚ β”‚ β”œβ”€β”€ NativeEventWaiter.cs # Windows Event waiting +β”‚ β”‚ β”œβ”€β”€ ResourceLoaderInstance.cs # Resource loader singleton +β”‚ β”‚ β”œβ”€β”€ SettingsDeepLink.cs # Deep link to Settings UI +β”‚ β”‚ β”œβ”€β”€ TrayIconService.cs # System tray integration +β”‚ β”‚ β”œβ”€β”€ TypePreservation.cs # AOT type preservation +β”‚ β”‚ └── WindowHelper.cs # Window utilities +β”‚ β”œβ”€β”€ PowerDisplayXAML/ +β”‚ β”‚ β”œβ”€β”€ App.xaml / App.xaml.cs # Application entry point +β”‚ β”‚ β”œβ”€β”€ MainWindow.xaml / .cs # Main UI window +β”‚ β”‚ β”œβ”€β”€ IdentifyWindow.xaml / .cs # Monitor identify overlay +β”‚ β”‚ └── MonitorIcon.xaml / .cs # Monitor icon control +β”‚ β”œβ”€β”€ Serialization/ +β”‚ β”‚ └── JsonSourceGenerationContext.cs # JSON source generation +β”‚ β”œβ”€β”€ Services/ +β”‚ β”‚ └── LightSwitchService.cs # LightSwitch theme change handling +β”‚ β”œβ”€β”€ Strings/ # Localization resources (en-us) +β”‚ β”œβ”€β”€ Telemetry/ +β”‚ β”‚ └── Events/ +β”‚ β”‚ └── PowerDisplayStartEvent.cs # Telemetry event +β”‚ β”œβ”€β”€ ViewModels/ +β”‚ β”‚ β”œβ”€β”€ ColorTemperatureItem.cs # Color temperature dropdown item +β”‚ β”‚ β”œβ”€β”€ InputSourceItem.cs # Input source dropdown item +β”‚ β”‚ β”œβ”€β”€ MainViewModel.cs # Main VM (partial class) +β”‚ β”‚ β”œβ”€β”€ MainViewModel.Monitors.cs # Monitor discovery methods +β”‚ β”‚ β”œβ”€β”€ MainViewModel.Settings.cs # Settings persistence methods +β”‚ β”‚ └── MonitorViewModel.cs # Per-monitor VM +β”‚ β”œβ”€β”€ GlobalUsings.cs # Global using directives +β”‚ └── Program.cs # Application entry point +β”‚ +β”œβ”€β”€ PowerDisplay.Lib.UnitTests/ # Unit tests +β”‚ β”œβ”€β”€ MccsCapabilitiesParserTests.cs +β”‚ └── MonitorMatchingHelperTests.cs +β”‚ +└── PowerDisplayModuleInterface/ # C++ DLL (module interface) + β”œβ”€β”€ dllmain.cpp # PowertoyModuleIface impl + β”œβ”€β”€ Constants.h # Module constants (event names, timeouts) + β”œβ”€β”€ resource.h # Resource definitions + β”œβ”€β”€ pch.h / pch.cpp # Precompiled headers + └── Trace.h / Trace.cpp # ETW telemetry tracing +``` + +--- + +## Component Design + +### PowerDisplay Module Internal Structure + +```mermaid +flowchart TB + subgraph ExternalInputs["External Inputs"] + ModuleInterface["Module Interface
(C++ DLL)"] + LightSwitch["LightSwitch Module"] + end + + subgraph WindowsEvents["Windows Events (IPC)"] + direction LR + ShowToggleEvents["Show/Toggle/Terminate
Events"] + ThemeChangedEvent["ThemeChanged
Events"] + end + + subgraph PowerDisplayModule["PowerDisplay Module"] + subgraph PowerDisplayApp["PowerDisplay App (WinUI 3)"] + MainViewModel + MonitorViewModel + MonitorManager + DisplayChangeWatcher["DisplayChangeWatcher
(Hot-Plug Detection)"] + LightSwitchService["LightSwitchService
(Theme Handler)"] + end + + subgraph PowerDisplayLib["PowerDisplay.Lib"] + subgraph Services + ProfileService + MonitorStateManager + DisplayRotationService + end + subgraph Drivers + DdcCiController + WmiController + end + subgraph Utils + PnpIdHelper["PnpIdHelper
(Manufacturer Names)"] + end + end + end + + subgraph Storage["Persistent Storage"] + SettingsJson[("settings.json")] + ProfilesJson[("profiles.json")] + MonitorStateJson[("monitor_state.json")] + end + + subgraph Hardware["Display Hardware"] + ExternalMonitor["External Monitor"] + LaptopDisplay["Laptop Display"] + end + + %% External to Windows Events + ModuleInterface -->|"SetEvent()"| ShowToggleEvents + LightSwitch -->|"SetEvent()"| ThemeChangedEvent + + %% Windows Events to App + ShowToggleEvents --> MainViewModel + ThemeChangedEvent --> LightSwitchService + + %% App internal + LightSwitchService -.->|"Get profile name"| MainViewModel + MainViewModel --> MonitorViewModel + MonitorViewModel --> MonitorManager + DisplayChangeWatcher -.->|"DisplayChanged event"| MainViewModel + + %% App to Lib services + MainViewModel --> ProfileService + MonitorViewModel --> MonitorStateManager + MonitorManager --> Drivers + MonitorManager --> DisplayRotationService + + %% Utils used during discovery + WmiController --> PnpIdHelper + + %% Services to Storage + ProfileService --> ProfilesJson + MonitorStateManager --> MonitorStateJson + + %% Drivers to Hardware + DdcCiController -->|"DDC/CI"| ExternalMonitor + WmiController -->|"WMI"| LaptopDisplay + DisplayRotationService -->|"ChangeDisplaySettingsEx"| ExternalMonitor + DisplayRotationService -->|"ChangeDisplaySettingsEx"| LaptopDisplay + + %% Force vertical layout: PowerDisplay.Lib above Storage/Hardware + PowerDisplayLib ~~~ Storage + PowerDisplayLib ~~~ Hardware + + %% Styling + style ExternalInputs fill:#e3f2fd,stroke:#1976d2 + style WindowsEvents fill:#fce4ec,stroke:#c2185b + style PowerDisplayModule fill:#fff8e1,stroke:#f57c00,stroke-width:2px + style PowerDisplayApp fill:#ffe0b2,stroke:#ef6c00 + style PowerDisplayLib fill:#c8e6c9,stroke:#388e3c + style Services fill:#a5d6a7,stroke:#2e7d32 + style Drivers fill:#ffccbc,stroke:#e64a19 + style Utils fill:#dcedc8,stroke:#689f38 + style Storage fill:#e1bee7,stroke:#8e24aa + style Hardware fill:#b2dfdb,stroke:#00897b +``` + +--- + +### DisplayChangeWatcher - Monitor Hot-Plug Detection + +The `DisplayChangeWatcher` component provides automatic detection of monitor connect/disconnect events using the WinRT DeviceWatcher API. + +**Key Features:** +- Uses `DisplayMonitor.GetDeviceSelector()` to watch for display device changes +- Implements 1-second debouncing to coalesce rapid connect/disconnect events +- Triggers `DisplayChanged` event to notify `MainViewModel` for monitor list refresh +- Runs continuously after initial monitor discovery completes + +**Implementation Details:** +```csharp +// Device selector for display monitors +string selector = DisplayMonitor.GetDeviceSelector(); +_deviceWatcher = DeviceInformation.CreateWatcher(selector); + +// Events monitored +_deviceWatcher.Added += OnDeviceAdded; // New monitor connected +_deviceWatcher.Removed += OnDeviceRemoved; // Monitor disconnected +_deviceWatcher.Updated += OnDeviceUpdated; // Monitor properties changed +``` + +**Debouncing Strategy:** +- Each device change event schedules a `DisplayChanged` event after 1 second +- Subsequent events within the debounce window cancel the previous timer +- This prevents excessive refreshes when multiple monitors change simultaneously + +--- + +### DDC/CI and WMI Interaction Architecture + +```mermaid +flowchart TB + subgraph Application["Application Layer"] + MM["MonitorManager"] + end + + subgraph Abstraction["Abstraction Layer"] + IMC["IMonitorController Interface"] + end + + subgraph Controllers["Controller Implementations"] + DDC["DdcCiController"] + WMI["WmiController"] + end + + subgraph DDCStack["DDC/CI Stack"] + DDCNative["DdcCiNative
(P/Invoke)"] + PhysicalMonitorMgr["PhysicalMonitorHandleManager"] + MonitorDiscovery["MonitorDiscoveryHelper"] + CapParser["MccsCapabilitiesParser"] + + subgraph Win32["Win32 APIs"] + User32["User32.dll
EnumDisplayMonitors
GetMonitorInfo"] + Dxva2["Dxva2.dll
GetVCPFeature
SetVCPFeature
Capabilities"] + end + end + + subgraph WMIStack["WMI Stack"] + WmiLight["WmiLight Library
(Native AOT compatible,
NuGet package)"] + PnpHelper["PnpIdHelper
(Manufacturer name lookup)"] + + subgraph WMIClasses["WMI Classes (root\\WMI)"] + WmiMonBright["WmiMonitorBrightness"] + WmiMonBrightMethods["WmiMonitorBrightnessMethods"] + end + end + + subgraph Hardware["Hardware Layer"] + ExtMon["External Monitor
(DDC/CI capable)"] + LaptopMon["Laptop Display
(WMI only)"] + end + + MM --> IMC + IMC -.-> DDC + IMC -.-> WMI + + DDC --> DDCNative + DDC --> PhysicalMonitorMgr + DDC --> MonitorDiscovery + DDC --> CapParser + + DDCNative --> User32 + DDCNative --> Dxva2 + MonitorDiscovery --> User32 + PhysicalMonitorMgr --> Dxva2 + + Dxva2 -->|"I2C/DDC"| ExtMon + + WMI --> WmiLight + WMI --> PnpHelper + WmiLight --> WmiMonBright + WmiLight --> WmiMonBrightMethods + + WmiMonBrightMethods -->|"WMI Provider"| LaptopMon + + style IMC fill:#bbdefb + style DDC fill:#c8e6c9 + style WMI fill:#ffccbc +``` + +### IMonitorController Interface Methods + +```mermaid +classDiagram + class IMonitorController { + <> + +Name: string + +DiscoverMonitorsAsync(cancellationToken) IEnumerable~Monitor~ + +GetBrightnessAsync(monitor, cancellationToken) VcpFeatureValue + +SetBrightnessAsync(monitor, brightness, cancellationToken) MonitorOperationResult + +SetContrastAsync(monitor, contrast, cancellationToken) MonitorOperationResult + +SetVolumeAsync(monitor, volume, cancellationToken) MonitorOperationResult + +GetColorTemperatureAsync(monitor, cancellationToken) VcpFeatureValue + +SetColorTemperatureAsync(monitor, vcpValue, cancellationToken) MonitorOperationResult + +GetInputSourceAsync(monitor, cancellationToken) VcpFeatureValue + +SetInputSourceAsync(monitor, inputSource, cancellationToken) MonitorOperationResult + +Dispose() + } + + class DdcCiController { + -_handleManager: PhysicalMonitorHandleManager + -_discoveryHelper: MonitorDiscoveryHelper + +Name: "DDC/CI Monitor Controller" + +DiscoverMonitorsAsync() + +GetBrightnessAsync(monitor) + +SetBrightnessAsync(monitor, brightness) + +SetContrastAsync(monitor, contrast) + +SetVolumeAsync(monitor, volume) + +GetColorTemperatureAsync(monitor) + +SetColorTemperatureAsync(monitor, colorTemperature) + +GetInputSourceAsync(monitor) + +SetInputSourceAsync(monitor, inputSource) + +GetCapabilitiesStringAsync(monitor) string + -GetVcpFeatureAsync(monitor, vcpCode) + -CollectCandidateMonitorsAsync() + -FetchCapabilitiesInParallelAsync() + -GetPhysicalMonitorsWithRetryAsync() + } + + class WmiController { + +Name: "WMI Monitor Controller" + +DiscoverMonitorsAsync() + +GetBrightnessAsync(monitor) + +SetBrightnessAsync(monitor, brightness) + +SetContrastAsync(monitor, contrast) + +SetVolumeAsync(monitor, volume) + +GetColorTemperatureAsync(monitor) + +SetColorTemperatureAsync(monitor, colorTemperature) + +GetInputSourceAsync(monitor) + +SetInputSourceAsync(monitor, inputSource) + -ExtractHardwareIdFromInstanceName() + -GetMonitorDisplayInfoByHardwareId() + } + + IMonitorController <|.. DdcCiController + IMonitorController <|.. WmiController +``` + +--- + +### Why WmiLight Instead of System.Management + +PowerDisplay uses the [WmiLight](https://github.com/MartinKuschnik/WmiLight) NuGet package +for WMI operations instead of the built-in `System.Management` namespace. This decision was +driven by several technical requirements: + +#### Native AOT Compatibility + +PowerDisplay is built with Native AOT (Ahead-of-Time compilation) enabled for improved startup +performance and reduced memory footprint. The standard `System.Management` namespace is **not +compatible with Native AOT** because it relies heavily on runtime reflection and COM interop +patterns that cannot be statically analyzed. + +WmiLight provides Native AOT support since version 5.0.0, making it the appropriate choice for +AOT-compiled applications. + +```xml + + + true + + + + +``` + +#### Memory Leak Prevention + +The `System.Management` implementation has a known issue where it leaks memory on each WMI +operation. While this might be acceptable for short-lived applications, PowerDisplay runs as +a long-running background process that may perform frequent WMI queries (e.g., polling +brightness levels, responding to theme changes). WmiLight addresses this memory leak issue. + +#### Lightweight API + +WmiLight provides a simpler, more lightweight API compared to `System.Management`: + +```csharp +// WmiLight - Simple and direct +using (var connection = new WmiConnection(@"root\WMI")) +{ + var results = connection.CreateQuery("SELECT * FROM WmiMonitorBrightness"); + foreach (var obj in results) + { + var brightness = obj.GetPropertyValue("CurrentBrightness"); + } +} + +// System.Management - More verbose +using (var searcher = new ManagementObjectSearcher(@"root\WMI", "SELECT * FROM WmiMonitorBrightness")) +{ + foreach (ManagementObject obj in searcher.Get()) + { + var brightness = (byte)obj["CurrentBrightness"]; + } +} +``` + +#### Comparison Summary + +| Aspect | System.Management | WmiLight | +|--------|-------------------|----------| +| **Native AOT Support** | ❌ Not supported | βœ… Supported (v5.0.0+) | +| **Memory Leaks** | ⚠️ Leaks on remote operations | βœ… No known leaks | +| **API Complexity** | More verbose | Simpler, lighter | +| **Long-running Services** | Not recommended | βœ… Recommended | +| **Static Linking** | ❌ Not available | βœ… Optional (`PublishWmiLightStaticallyLinked`) | + +#### References + +- [WmiLight GitHub Repository](https://github.com/MartinKuschnik/WmiLight) +- [WmiLight NuGet Package](https://www.nuget.org/packages/WmiLight) + +--- + +### Why We Need an MCCS Capabilities String Parser + +DDC/CI monitors report their supported features via a **capabilities string** - a structured +text format defined by the VESA MCCS (Monitor Control Command Set) standard. This string +tells PowerDisplay which VCP codes the monitor supports and what values are valid for each. + +#### Example Capabilities String + +``` +(prot(monitor)type(lcd)model(PD3220U)cmds(01 02 03 07)vcp(10 12 14(04 05 06) 60(11 12 0F))mccs_ver(2.2)) +``` + +This string encodes: +- **Protocol**: monitor +- **Type**: LCD display +- **Model**: PD3220U +- **Supported commands**: 0x01, 0x02, 0x03, 0x07 +- **VCP codes**: 0x10 (brightness), 0x12 (contrast), 0x14 (color preset with values 4,5,6), 0x60 (input source with values 0x11, 0x12, 0x0F) +- **MCCS version**: 2.2 + +#### Why Parse It? + +| Use Case | How Parser Helps | +|----------|------------------| +| **Feature Detection** | Determine if monitor supports contrast, volume, color temperature, input switching | +| **Input Source Dropdown** | Extract valid input source values (e.g., HDMI-1=0x11, DP=0x0F) for UI dropdown | +| **Color Preset List** | Extract supported color presets (e.g., sRGB, 5000K, 6500K) | +| **Diagnostics** | Display raw VCP codes in Settings UI for troubleshooting | +| **PIP/PBP Support** | Parse window capabilities for Picture-in-Picture features | + +#### Why Not Use Regex? + +The MCCS capabilities string format has **nested parentheses** that regex cannot reliably handle: + +``` +vcp(10 12 14(04 05 06) 60(11 12 0F)) + ^^^^^^^^^^^^ nested values +``` + +A recursive descent parser properly handles: +- Nested parentheses at arbitrary depth +- Variable whitespace (some monitors use `01 02 03`, others use `010203`) +- Optional outer parentheses (some monitors omit them) +- Unknown segments (graceful skip without failing) + +#### Implementation + +PowerDisplay implements a **zero-allocation recursive descent parser** using `ref struct` and +`ReadOnlySpan` for optimal performance during monitor discovery. + +```csharp +// Usage in DdcCiController +var result = MccsCapabilitiesParser.Parse(capabilitiesString); +if (result.IsValid) +{ + monitor.VcpCapabilitiesInfo = result.Capabilities; + // Now we know which features this monitor supports +} +``` + +> **Detailed Design:** See [mccsParserDesign.md](./mccsParserDesign.md) for the complete +> parser architecture, grammar definition, and implementation details. + +--- + +### Monitor Identification: Handles, IDs, and Names + +Understanding how Windows identifies monitors is critical for PowerDisplay's operation. +Different Windows APIs use different identifiers, and PowerDisplay must correlate these +to provide a unified view across DDC/CI and WMI subsystems. + +#### Windows Display Subsystem Overview + +```mermaid +flowchart TB + subgraph WindowsAPIs["Windows Display APIs"] + EnumDisplayMonitors["EnumDisplayMonitors
(User32.dll)"] + QueryDisplayConfig["QueryDisplayConfig
(User32.dll)"] + GetPhysicalMonitors["GetPhysicalMonitorsFromHMONITOR
(Dxva2.dll)"] + WmiMonitor["WMI root\\WMI
(WmiLight)"] + end + + subgraph Identifiers["Monitor Identifiers"] + HMONITOR["HMONITOR
(Logical Monitor Handle)"] + GdiDeviceName["GDI Device Name
(e.g., \\\\.\\DISPLAY1)"] + PhysicalHandle["Physical Monitor Handle
(IntPtr for DDC/CI)"] + DevicePath["Device Path
(Unique per target)"] + HardwareId["Hardware ID
(e.g., DEL41B4)"] + InstanceName["WMI Instance Name
(e.g., DISPLAY\\BOE0900\\...)"] + MonitorNumber["Monitor Number
(1-based, matches Windows Settings)"] + end + + EnumDisplayMonitors --> HMONITOR + HMONITOR --> GdiDeviceName + GetPhysicalMonitors --> PhysicalHandle + + QueryDisplayConfig --> GdiDeviceName + QueryDisplayConfig --> DevicePath + QueryDisplayConfig --> HardwareId + QueryDisplayConfig --> MonitorNumber + + WmiMonitor --> InstanceName + InstanceName --> HardwareId + + style HMONITOR fill:#e3f2fd + style GdiDeviceName fill:#fff3e0 + style PhysicalHandle fill:#c8e6c9 + style DevicePath fill:#f3e5f5 + style HardwareId fill:#ffccbc + style InstanceName fill:#ffe0b2 + style MonitorNumber fill:#b2dfdb +``` + +#### Identifier Definitions + +| Identifier | Source | Format | Example | Scope | +|------------|--------|--------|---------|-------| +| **HMONITOR** | `EnumDisplayMonitors` | `IntPtr` | `0x00010001` | Logical monitor (may represent multiple physical monitors in clone mode) | +| **GDI Device Name** | `GetMonitorInfo` / `QueryDisplayConfig` | String | `\\.\DISPLAY1` | Adapter output; multiple targets can share same GDI name in mirror mode | +| **Physical Monitor Handle** | `GetPhysicalMonitorsFromHMONITOR` | `IntPtr` | `0x00000B14` | DDC/CI communication handle; valid for `GetVCPFeature` / `SetVCPFeature` | +| **Device Path** | `QueryDisplayConfig` | String | `\\?\DISPLAY#DEL41B4#5&12a3b4c&0&UID123#{...}` | Unique per target; used as primary key in `MonitorDisplayInfo` | +| **Hardware ID** | EDID (via `QueryDisplayConfig`) | String | `DEL41B4` | Manufacturer (3-char PnP ID) + Product Code (4-char hex); identifies monitor model | +| **WMI Instance Name** | `WmiMonitorBrightness` | String | `DISPLAY\BOE0900\4&10fd3ab1&0&UID265988_0` | WMI object identifier; contains hardware ID in second segment | +| **Monitor Number** | `QueryDisplayConfig` path index | Integer | `1`, `2`, `3` | 1-based; matches Windows Settings β†’ Display β†’ "Identify" feature | + +#### DDC/CI Monitor Discovery Flow + +```mermaid +sequenceDiagram + participant App as PowerDisplay + participant Enum as EnumDisplayMonitors + participant Info as GetMonitorInfo + participant QDC as QueryDisplayConfig + participant Phys as GetPhysicalMonitors + participant DDC as DDC/CI (I2C) + + App->>Enum: EnumDisplayMonitors(callback) + Enum-->>App: HMONITOR handles + + loop For each HMONITOR + App->>Info: GetMonitorInfo(hMonitor) + Info-->>App: GDI Device Name (e.g., "\\.\DISPLAY1") + + App->>Phys: GetPhysicalMonitorsFromHMONITOR(hMonitor) + Phys-->>App: Physical Monitor Handle(s) + Description + end + + App->>QDC: QueryDisplayConfig(QDC_ONLY_ACTIVE_PATHS) + QDC-->>App: MonitorDisplayInfo[] (DevicePath, GdiDeviceName, HardwareId, MonitorNumber) + + Note over App: Match Physical Handles to MonitorDisplayInfo
using GDI Device Name + + loop For each Physical Handle + App->>DDC: GetCapabilitiesStringLength(handle) + DDC-->>App: Capabilities length + App->>DDC: CapabilitiesRequestAndCapabilitiesReply(handle) + DDC-->>App: Capabilities string (MCCS format) + end + + Note over App: Create Monitor objects with:
- Handle (Physical Monitor Handle)
- MonitorNumber (from QueryDisplayConfig)
- GdiDeviceName (for rotation APIs) +``` + +#### WMI Monitor Discovery Flow + +```mermaid +sequenceDiagram + participant App as PowerDisplay + participant WMI as WmiLight + participant QDC as QueryDisplayConfig + participant PnP as PnpIdHelper + + App->>WMI: Query WmiMonitorBrightness + WMI-->>App: InstanceName, CurrentBrightness + + Note over App: Extract HardwareId from InstanceName
"DISPLAY\BOE0900\..." β†’ "BOE0900" + + App->>QDC: GetAllMonitorDisplayInfo() + QDC-->>App: MonitorDisplayInfo[] (keyed by DevicePath) + + Note over App: Match WMI monitor to QueryDisplayConfig
by comparing HardwareId + + App->>PnP: GetBuiltInDisplayName("BOE0900") + PnP-->>App: "BOE Built-in Display" + + Note over App: Create Monitor objects with:
- InstanceName (for WMI queries)
- MonitorNumber (from QueryDisplayConfig)
- GdiDeviceName (for rotation APIs) +``` + +#### Key Relationships + +##### GDI Device Name ↔ Physical Monitors + +```mermaid +flowchart TB + HMON["HMONITOR (Logical)"] + + HMON --> GDI["GetMonitorInfo()
β†’ GDI Device Name
\.DISPLAY1"] + HMON --> GetPhys["GetPhysicalMonitorsFromHMONITOR()"] + + GetPhys --> PM0["Physical Monitor 0
Handle: 0x0B14
Desc: Dell U2722D"] + GetPhys --> PM1["Physical Monitor 1
Handle: 0x0B18
Desc: Dell U2722D
Mirror mode"] + + style HMON fill:#e3f2fd + style PM0 fill:#fff3e0 + style PM1 fill:#fff3e0 +``` + +In **mirror/clone mode**, multiple physical monitors share the same GDI device name. +QueryDisplayConfig returns multiple paths with the same `GdiDeviceName` but different +`DevicePath` values, allowing us to distinguish them. + +##### DisplayPort Daisy Chain (MST - Multi-Stream Transport) + +**Daisy chaining** allows multiple monitors to be connected in series through a single +DisplayPort output using MST (Multi-Stream Transport) technology. This creates unique +challenges for monitor identification. + +```mermaid +flowchart LR + GPU["GPU
(Single DP Port)"] + MonA["Monitor A
(MST Hub)"] + MonB["Monitor B
(End)"] + + GPU -->|"DP"| MonA -->|"DP"| MonB + + subgraph Result["Result: Multiple Logical Displays"] + D1["DISPLAY1"] + D2["DISPLAY2"] + end + + GPU -.-> Result + + style GPU fill:#bbdefb + style MonA fill:#c8e6c9 + style MonB fill:#c8e6c9 + style Result fill:#fff3e0 +``` + +**How Windows Handles MST:** + +| Aspect | Behavior | +|--------|----------| +| **HMONITOR** | Each daisy-chained monitor gets its own HMONITOR | +| **GDI Device Name** | Each monitor gets a unique GDI name (e.g., `\\.\DISPLAY1`, `\\.\DISPLAY2`) | +| **Physical Monitor Handle** | Each monitor has its own physical handle for DDC/CI | +| **Device Path** | Unique for each monitor in the chain | +| **Hardware ID** | Different if monitors are different models; same if identical models | + +**MST vs Clone Mode Comparison:** + +| Property | MST Daisy Chain (Extended Desktop) | Clone/Mirror Mode | +|----------|-----------------------------------|-------------------| +| **HMONITOR** | Separate per monitor (HMONITOR_1, HMONITOR_2, ...) | Shared (single HMONITOR_1) | +| **GDI Device Name** | Unique per monitor (`\\.\DISPLAY1`, `\\.\DISPLAY2`, ...) | Shared (`\\.\DISPLAY1`) | +| **Physical Handle** | One per HMONITOR (A, B, C) | Multiple per HMONITOR (A, B) | +| **DevicePath** | Unique per monitor (unique1, unique2, ...) | Unique per monitor (unique1, unique2) | +| **Behavior** | Each monitor = independent logical display | Multiple monitors share same logical display | + +**PowerDisplay Handling of MST:** + +1. **Discovery**: `EnumDisplayMonitors` returns separate HMONITOR for each MST monitor +2. **Physical Handles**: `GetPhysicalMonitorsFromHMONITOR` returns one handle per HMONITOR +3. **Matching**: QueryDisplayConfig provides unique DevicePath for each MST target +4. **DDC/CI**: Each monitor in the chain can be controlled independently via its handle + +**Identifying Same-Model Monitors in Daisy Chain:** + +When multiple identical monitors are daisy-chained (same Hardware ID), PowerDisplay +distinguishes them using: + +- **MonitorNumber**: Different path index in QueryDisplayConfig (1, 2, 3...) +- **DevicePath**: Unique system-generated path for each target +- **Monitor.Id**: Format `DDC_{HardwareId}_{MonitorNumber}` ensures uniqueness + +Example with two identical Dell U2722D monitors: + +| Monitor | Id | MonitorNumber | +|---------|-----|---------------| +| Monitor 1 | `DDC_DEL41B4_1` | 1 | +| Monitor 2 | `DDC_DEL41B4_2` | 2 | + +##### Connection Mode Summary + +| Mode | HMONITOR | GDI Device Name | Physical Handles | Use Case | +|------|----------|-----------------|------------------|----------| +| **Standard** (separate cables) | 1 per monitor | Unique per monitor | 1 per HMONITOR | Most common setup | +| **Clone/Mirror** | 1 shared | Shared | Multiple per HMONITOR | Presentation, duplication | +| **MST Daisy Chain** | 1 per monitor | Unique per monitor | 1 per HMONITOR | Reduced cable clutter | +| **USB-C/Thunderbolt Hub** | 1 per monitor | Unique per monitor | 1 per HMONITOR | Laptop docking | + +**Key Insight**: From PowerDisplay's perspective, MST daisy chain and standard multi-cable +setups behave identically - each monitor appears as an independent display with unique +identifiers. Only clone/mirror mode requires special handling due to shared HMONITOR/GDI names. + +##### Hardware ID Composition + +```mermaid +flowchart TB + HardwareId["Hardware ID: DEL41B4"] + + HardwareId --> PnpId["DEL
PnP Manufacturer ID
3 chars, EDID bytes 8-9"] + HardwareId --> ProductCode["41B4
Product Code
4 hex chars, EDID bytes 10-11"] + + style HardwareId fill:#fff3e0 + style PnpId fill:#c8e6c9 + style ProductCode fill:#bbdefb +``` + +The **PnP Manufacturer ID** is a 3-character code assigned by UEFI Forum. +Common laptop display manufacturers: + +| PnP ID | Manufacturer | +|--------|--------------| +| `BOE` | BOE Technology | +| `LGD` | LG Display | +| `AUO` | AU Optronics | +| `CMN` | Chi Mei Innolux | +| `SDC` | Samsung Display | +| `SHP` | Sharp | +| `LEN` | Lenovo | +| `DEL` | Dell | + +##### WMI Instance Name Parsing + +```mermaid +flowchart TB + InstanceName["WMI InstanceName:
DISPLAY\BOE0900\4#amp;10fd3ab1#amp;0#amp;UID265988_0"] + + InstanceName --> Seg1["Segment 1: DISPLAY
Constant prefix"] + InstanceName --> Seg2["Segment 2: BOE0900
Hardware ID
Used for matching with QueryDisplayConfig"] + InstanceName --> Seg3["Segment 3: Device instance
4#amp;10fd3ab1#amp;0#amp;UID265988_0"] + + style InstanceName fill:#fff3e0 + style Seg1 fill:#e0e0e0 + style Seg2 fill:#c8e6c9 + style Seg3 fill:#e0e0e0 +``` + +##### Monitor Number (Windows Display Settings) + +The `MonitorNumber` in PowerDisplay corresponds exactly to the number shown in: +- Windows Settings β†’ System β†’ Display β†’ "Identify" button +- The number overlay that appears on each display + +This is derived from the **path index** in `QueryDisplayConfig`: +- `paths[0]` β†’ Monitor 1 +- `paths[1]` β†’ Monitor 2 +- etc. + +#### Display Rotation and GDI Device Name + +The `ChangeDisplaySettingsEx` API requires the **GDI Device Name** to target a specific display: + +```cpp +// Correct: Target specific display by GDI name +ChangeDisplaySettingsEx("\\.\DISPLAY2", &devMode, NULL, 0, NULL); + +// Wrong: NULL affects primary display only +ChangeDisplaySettingsEx(NULL, &devMode, NULL, 0, NULL); +``` + +PowerDisplay stores `GdiDeviceName` in each `Monitor` object specifically for rotation operations. + +#### Cross-Reference Summary + +| PowerDisplay Property | DDC/CI Source | WMI Source | +|-----------------------|---------------|------------| +| `Monitor.Id` | `"DDC_{HardwareId}_{MonitorNumber}"` | `"WMI_{HardwareId}_{MonitorNumber}"` | +| `Monitor.Handle` | Physical Monitor Handle | N/A (uses InstanceName) | +| `Monitor.InstanceName` | N/A | WMI InstanceName | +| `Monitor.GdiDeviceName` | QueryDisplayConfig | QueryDisplayConfig | +| `Monitor.MonitorNumber` | QueryDisplayConfig path index | QueryDisplayConfig (matched by HardwareId) | +| `Monitor.Name` | EDID FriendlyName or Description | PnpIdHelper.GetBuiltInDisplayName() | + +--- + +### Settings UI and PowerDisplay Interaction Architecture + +```mermaid +flowchart LR + subgraph SettingsUI["Settings UI Process"] + direction TB + Page["PowerDisplayPage.xaml"] + VM["PowerDisplayViewModel"] + Page --> VM + end + + subgraph Runner["Runner Process"] + direction TB + Exe["PowerToys.exe"] + Pipe["Named Pipe IPC"] + Module["PowerDisplayModuleInterface.dll"] + Pipe --> Exe --> Module + end + + subgraph PDApp["PowerDisplay Process"] + direction TB + MainVM["MainViewModel"] + Events["Event Listeners
Refresh / Profile"] + Events --> MainVM + end + + subgraph Storage["File System"] + direction TB + Settings[("settings.json")] + Profiles[("profiles.json")] + end + + %% Main flow: Settings UI β†’ Runner β†’ PowerDisplay + VM -->|"IPC Message"| Pipe + Module -->|"SetEvent()"| Events + + %% File access + VM <-.->|"Read/Write"| Settings + VM <-.->|"Read/Write"| Profiles + MainVM <-.->|"Read"| Settings + MainVM <-.->|"Read/Write"| Profiles + + style SettingsUI fill:#e3f2fd + style Runner fill:#fff3e0 + style PDApp fill:#e8f5e9 + style Storage fill:#fffde7 +``` + +**Data Models (in Settings.UI.Library):** + +| Model | Purpose | +|-------|---------| +| `PowerDisplaySettings` | Main settings container with properties | +| `MonitorInfo` | Per-monitor settings displayed in Settings UI (includes feature visibility flags like `EnableColorTemperature`) | + +### Windows Events for IPC + +Event names use fixed GUID suffixes to ensure uniqueness (defined in `shared_constants.h`). + +| Constant | Direction | Purpose | +|----------|-----------|---------| +| `TOGGLE_POWER_DISPLAY_EVENT` | Runner β†’ App | Toggle visibility | +| `TERMINATE_POWER_DISPLAY_EVENT` | Runner β†’ App | Terminate process | +| `REFRESH_POWER_DISPLAY_MONITORS_EVENT` | Settings β†’ App | Refresh monitor list | +| `SETTINGS_UPDATED_POWER_DISPLAY_EVENT` | Settings β†’ App | Notify settings changed (feature visibility, tray icon) | +| `LightSwitchLightThemeEventName` | LightSwitch β†’ App | Apply light mode profile | +| `LightSwitchDarkThemeEventName` | LightSwitch β†’ App | Apply dark mode profile | + +**Profile Application via Named Pipe IPC:** + +Profile application from Settings UI uses Named Pipe IPC (via Runner's `call_custom_action`) instead of +Windows Events. When the user clicks "Apply" on a profile in Settings UI, the message is sent through +the Runner to the Module Interface, which forwards it to PowerDisplay.exe via Named Pipe. + +**Event Name Format:** `Local\PowerToysPowerDisplay-{EventType}-{GUID}` + +Example: `Local\PowerToysPowerDisplay-ToggleEvent-5f1a9c3e-7d2b-4e8f-9a6c-3b5d7e9f1a2c` + +--- + +### LightSwitch Profile Integration Architecture + +```mermaid +flowchart TB + subgraph LightSwitchModule["LightSwitch Module (C++)"] + StateManager["LightSwitchStateManager"] + ThemeEval["Theme Evaluation
(Time/System)"] + LightSwitchSettings["LightSwitchSettings"] + NotifyPD["NotifyPowerDisplay(isLight)"] + end + + subgraph PowerDisplayModule["PowerDisplay Module (C#)"] + subgraph App["PowerDisplay App"] + EventWaiter["NativeEventWaiter
(Background Thread)"] + LightSwitchSvc["LightSwitchService
(Static Helper)"] + MainViewModel["MainViewModel"] + end + + ProfileService["ProfileService"] + MonitorVMs["MonitorViewModels"] + Controllers["IMonitorController"] + end + + subgraph WindowsEvents["Windows Events"] + LightEvent["Local\\PowerToys_LightSwitch_LightTheme"] + DarkEvent["Local\\PowerToys_LightSwitch_DarkTheme"] + end + + subgraph FileSystem["File System"] + LSSettingsJson["LightSwitch/settings.json
{lightProfile, darkProfile}"] + PDProfilesJson["PowerDisplay/profiles.json
{profiles: [...]}"] + end + + subgraph Hardware["Hardware"] + Monitors["Connected Monitors"] + end + + %% LightSwitch flow + ThemeEval -->|"Time boundary
or manual"| StateManager + StateManager --> LightSwitchSettings + StateManager --> NotifyPD + NotifyPD -->|"isLight=true"| LightEvent + NotifyPD -->|"isLight=false"| DarkEvent + + %% PowerDisplay flow - theme determined from event + LightEvent -->|"Event signaled"| EventWaiter + DarkEvent -->|"Event signaled"| EventWaiter + EventWaiter -->|"isLightMode"| LightSwitchSvc + LightSwitchSvc -->|"GetProfileForTheme()"| LSSettingsJson + LightSwitchSvc -->|"Profile name"| MainViewModel + MainViewModel -->|"LoadProfiles()"| ProfileService + ProfileService <--> PDProfilesJson + MainViewModel -->|"ApplyProfileAsync()"| MonitorVMs + MonitorVMs --> Controllers + Controllers --> Monitors + + style LightSwitchModule fill:#ffccbc + style PowerDisplayModule fill:#c8e6c9 + style App fill:#a5d6a7 + style WindowsEvents fill:#e3f2fd + style FileSystem fill:#fffde7 +``` + +### LightSwitch Settings JSON Structure + +```json +{ + "properties": { + "apply_monitor_settings": { "value": true }, + "enable_light_mode_profile": { "value": true }, + "light_mode_profile": { "value": "Productivity" }, + "enable_dark_mode_profile": { "value": true }, + "dark_mode_profile": { "value": "Night Mode" } + } +} +``` + +--- + +## Data Flow and Communication + +### Monitor Discovery Flow + +```mermaid +flowchart TB + Start([Start Discovery]) + Start --> MM["MonitorManager.DiscoverMonitorsAsync()"] + + MM --> DDC["DdcCiController.DiscoverMonitorsAsync()"] + MM --> WMI["WmiController.DiscoverMonitorsAsync()"] + + DDC --> Merge["Merge Results"] + WMI --> Merge + + Merge --> Sort["Sort by MonitorNumber"] + Sort --> Update["UpdateMonitorList()"] + Update --> Check{"RestoreSettingsOnStartup?"} + Check -->|Yes| Restore["RestoreMonitorSettingsAsync()
(Set hardware values)"] + Check -->|No| Done + Restore --> Done([Discovery Complete]) + + style Start fill:#e8f5e9 + style Done fill:#e8f5e9 + style DDC fill:#e3f2fd + style WMI fill:#fff3e0 + style Restore fill:#fff9c4 +``` + +> **Note:** DDC/CI and WMI discovery run in parallel via `Task.WhenAll`. +> +> **Settings Restore:** When `RestoreSettingsOnStartup` is enabled, `RestoreMonitorSettingsAsync()` is called +> after monitor discovery to restore saved brightness, contrast, color temperature, and volume values +> to the hardware. The UI remains in "scanning" state until restore completes. + +#### DDC/CI Discovery (Three-Phase Approach) + +**Phase 1: Collect Candidates** + +```mermaid +flowchart LR + QDC["QueryDisplayConfig"] --> Match["Match by GDI Name"] + Enum["EnumDisplayMonitors"] --> GetPhys["GetPhysicalMonitors"] --> Match + Match --> Candidates["CandidateMonitor List"] + + style QDC fill:#e3f2fd + style Enum fill:#e3f2fd +``` + +**Phase 2: Fetch Capabilities (Parallel)** + +```mermaid +flowchart LR + Candidates["CandidateMonitor List"] --> Fetch["Task.WhenAll:
FetchCapabilities
~4s per monitor via I2C"] + Fetch --> Results["DdcCiValidationResult Array"] + + style Fetch fill:#fff3e0 +``` + +**Phase 3: Create Monitors** + +```mermaid +flowchart LR + Results["Validation Results"] --> Check{"IsValid?"} + Check -->|Yes| Create["Create Monitor"] + Create --> Init["Initialize VCP Values:
Brightness, ColorTemp, InputSource"] + Init --> Add["Add to List"] + Check -->|No| Skip([Skip]) + + style Create fill:#e8f5e9 + style Init fill:#e8f5e9 +``` + +#### WMI Discovery + +```mermaid +flowchart LR + Query["Query WmiMonitorBrightness"] --> Extract["Extract HardwareId
from InstanceName"] + QDC["QueryDisplayConfig"] --> Match["Match by HardwareId"] + Extract --> Match + Match --> Name["Get Display Name
via PnpIdHelper"] + Name --> Create["Create Monitor
Brightness + WMI"] + + style Query fill:#fff3e0 + style Create fill:#fff3e0 +``` + +#### Key Differences + +| Aspect | DDC/CI | WMI | +|--------|--------|-----| +| **Target** | External monitors | Internal laptop displays | +| **Capabilities** | Full VCP support (brightness, contrast, volume, color temp, input) | Brightness only | +| **Discovery** | Three-phase with parallel I2C fetching | Single WMI query | +| **Initialization** | Reads current values for all supported VCP codes | Brightness from query result | +| **Performance** | ~4s per monitor (I2C), parallelized | Fast (~100ms total) | + +--- + +## Sequence Diagrams + +### Sequence: Modifying Color Temperature in Flyout UI + +Color temperature adjustment is now handled directly in the PowerDisplay Flyout UI, +providing a more responsive user experience without requiring IPC round-trips to Settings UI. + +```mermaid +sequenceDiagram + participant User + participant Flyout as MainWindow (Flyout) + participant MonitorVM as MonitorViewModel + participant MonitorManager + participant Controller as DdcCiController + participant StateManager as MonitorStateManager + participant Monitor as Physical Monitor + + User->>Flyout: Opens PowerDisplay flyout
(via hotkey or tray icon) + + Note over Flyout: Color temperature switcher visible
(if enabled in Settings) + + User->>Flyout: Selects color temperature preset
from dropdown (e.g., 6500K) + + Flyout->>MonitorVM: ColorTemperatureListView_SelectionChanged + MonitorVM->>MonitorVM: SetColorTemperatureAsync(vcpValue) + + MonitorVM->>MonitorManager: SetColorTemperatureAsync(monitor, vcpValue) + + MonitorManager->>Controller: SetColorTemperatureAsync(monitor, vcpValue) + Controller->>Controller: SetVcpFeatureAsync(VcpCodeColorTemperature) + Controller->>Monitor: SetVCPFeature(0x14, vcpValue) + Monitor-->>Controller: OK + + Controller-->>MonitorManager: MonitorOperationResult.Success + MonitorManager-->>MonitorVM: Success + + MonitorVM->>MonitorVM: RefreshAvailableColorPresets() + Note over MonitorVM: Regenerate ColorTemperatureItem list
with updated IsSelected flags + + MonitorVM->>StateManager: UpdateMonitorParameter("ColorTemperature", vcpValue) + + Note over StateManager: Debounced save (2 seconds) + StateManager->>StateManager: Schedule file write + + Note over StateManager: After 2s idle + StateManager->>StateManager: SaveToFile(monitor_state.json) + + Note over MonitorVM: UI updates to show
selected preset with checkmark +``` + +**Color Temperature Selection UI:** + +The color temperature switcher displays a list of available presets (e.g., 5000K, 6500K, sRGB). Each preset +shows a checkmark icon when selected. The `ColorTemperatureItem` class stores `IsSelected` state, which is +updated by regenerating the entire `AvailableColorPresets` list after a successful color temperature change. +This ensures the checkmark displays correctly for the newly selected preset. + +**Flyout Display Options:** + +The Flyout UI visibility is controlled by a combination of global settings and per-monitor settings: + +**Global Settings (in `PowerDisplayProperties`):** + +| Setting | Default | Description | +|---------|---------|-------------| +| `ShowProfileSwitcher` | `true` | Show profile switcher (also requires profiles to exist) | +| `ShowIdentifyMonitorsButton` | `true` | Show "Identify Monitors" button | + +**Per-Monitor Settings (in `MonitorInfo`):** + +| Setting | Default | Description | +|---------|---------|-------------| +| `EnableContrast` | `true` (if supported) | Show contrast slider for this monitor | +| `EnableVolume` | `true` (if supported) | Show volume slider for this monitor | +| `EnableInputSource` | `true` (if supported) | Show input source selector for this monitor | +| `EnableRotation` | `true` | Show rotation control for this monitor | +| `EnableColorTemperature` | `true` (if supported) | Show color temperature switcher for this monitor | +| `IsHidden` | `false` | Hide this monitor from the flyout entirely | + +Users can configure per-monitor visibility in Settings UI under the "Monitors" section. Each monitor +shows checkboxes for the features it supports, allowing fine-grained control over the flyout UI. + +**Color Temperature Warning Dialog:** + +When enabling `EnableColorTemperature` for a monitor in Settings UI, a warning dialog is displayed to inform +users about potential risks. Color temperature changes can cause unpredictable results on some monitors, +including incorrect colors, display malfunction, or settings that cannot be reverted. The dialog requires +explicit confirmation before enabling the feature. + +Implementation notes: +- The warning dialog only appears when the user explicitly checks the checkbox (not during initial page load) +- A `_isPageLoaded` flag prevents the dialog from appearing during data binding +- If the user cancels the dialog, the checkbox is reverted to unchecked state + +--- + +### Sequence: Creating and Saving a Profile + +```mermaid +sequenceDiagram + participant User + participant SettingsPage as PowerDisplayPage + participant ViewModel as PowerDisplayViewModel + participant ProfileDialog as ProfileEditorDialog + participant ProfileService + participant FileSystem as profiles.json + + User->>SettingsPage: Clicks "Add Profile" button + SettingsPage->>ViewModel: ShowProfileEditor() + + ViewModel->>ProfileDialog: Show(monitors, existingProfiles) + ProfileDialog->>ProfileDialog: Display monitor selection UI + + User->>ProfileDialog: Enters profile name + User->>ProfileDialog: Selects monitors to include + User->>ProfileDialog: Configures settings per monitor
(brightness, contrast, etc.) + User->>ProfileDialog: Clicks "Save" + + ProfileDialog->>ProfileDialog: Validate inputs + Note over ProfileDialog: Check name unique,
at least one monitor selected + + ProfileDialog-->>ViewModel: ResultProfile (PowerDisplayProfile) + + ViewModel->>ProfileService: AddOrUpdateProfile(profile) + + ProfileService->>ProfileService: lock(_lock) + ProfileService->>FileSystem: Read profiles.json + FileSystem-->>ProfileService: Existing profiles + ProfileService->>ProfileService: Add/update profile in collection + ProfileService->>ProfileService: Set LastUpdated = DateTime.Now + ProfileService->>FileSystem: Write profiles.json + FileSystem-->>ProfileService: Success + ProfileService-->>ViewModel: true + + ViewModel->>ViewModel: RefreshProfilesList() + ViewModel-->>SettingsPage: PropertyChanged(Profiles) + SettingsPage->>SettingsPage: Update UI with new profile +``` + +--- + +### Sequence: Applying Profile via LightSwitch Theme Change + +```mermaid +sequenceDiagram + participant System as Windows System + participant LightSwitch as LightSwitchStateManager (C++) + participant WinEvent as Windows Events + participant EventWaiter as NativeEventWaiter + participant LSSvc as LightSwitchService + participant MainVM as MainViewModel + participant ProfileService + participant MonitorVM as MonitorViewModel + participant Controller as IMonitorController + participant Monitor as Physical Monitor + + Note over System: Time reaches threshold
or user changes theme + System->>LightSwitch: Theme change detected + + LightSwitch->>LightSwitch: EvaluateAndApplyIfNeeded() + LightSwitch->>LightSwitch: ApplyTheme(isLight) + + LightSwitch->>LightSwitch: NotifyPowerDisplay(isLight) + Note over LightSwitch: Check if profile enabled + + alt isLight == true + LightSwitch->>WinEvent: SetEvent("Local\\PowerToys_LightSwitch_LightTheme") + else isLight == false + LightSwitch->>WinEvent: SetEvent("Local\\PowerToys_LightSwitch_DarkTheme") + end + + Note over EventWaiter: Background thread waiting
on both Light and Dark events + EventWaiter->>WinEvent: WaitAny([lightEvent, darkEvent]) returns index + + Note over EventWaiter: Theme determined from event:
index 0 = Light, index 1 = Dark + EventWaiter->>LSSvc: GetProfileForTheme(isLightMode) + LSSvc->>LSSvc: Read LightSwitch/settings.json + LSSvc-->>EventWaiter: profileName (or null) + + EventWaiter->>MainVM: Dispatch to UI thread with profileName + + MainVM->>ProfileService: LoadProfiles() + ProfileService-->>MainVM: PowerDisplayProfiles + + MainVM->>MainVM: Find profile by name + MainVM->>MainVM: ApplyProfileAsync(profile.MonitorSettings) + + loop For each ProfileMonitorSetting + MainVM->>MainVM: Find MonitorViewModel by InternalName + + alt Brightness specified + MainVM->>MonitorVM: SetBrightnessAsync(value, immediate=true) + MonitorVM->>Controller: SetBrightnessAsync(monitor, value) + Controller->>Monitor: DDC/CI or WMI call + Monitor-->>Controller: Success + end + + alt Contrast specified + MainVM->>MonitorVM: SetContrastAsync(value, immediate=true) + MonitorVM->>Controller: SetContrastAsync(monitor, value) + Controller->>Monitor: SetVCPFeature(0x12, value) + end + + alt Volume specified + MainVM->>MonitorVM: SetVolumeAsync(value, immediate=true) + MonitorVM->>Controller: SetVolumeAsync(monitor, value) + Controller->>Monitor: SetVCPFeature(0x62, value) + end + + alt ColorTemperature specified + MainVM->>MonitorVM: SetColorTemperatureAsync(vcpValue) + MonitorVM->>Controller: SetColorTemperatureAsync(monitor, vcpValue) + Controller->>Monitor: SetVCPFeature(0x14, vcpValue) + end + + alt Orientation specified + MainVM->>MonitorVM: SetOrientationAsync(orientation) + MonitorVM->>Controller: SetRotationAsync(monitor, orientation) + Controller->>Monitor: ChangeDisplaySettingsEx + end + end + + Note over MainVM: await Task.WhenAll(updateTasks) + MainVM->>MainVM: Log profile application complete +``` + +--- + +### Sequence: UI Slider Adjustment (Brightness) + +```mermaid +sequenceDiagram + participant User + participant Slider as Brightness Slider + participant MonitorVM as MonitorViewModel + participant Debouncer as SimpleDebouncer + participant MonitorManager + participant Controller as DdcCiController + participant StateManager as MonitorStateManager + participant Monitor as Physical Monitor + + User->>Slider: Drags slider (continuous) + + loop During drag (multiple events) + Slider->>MonitorVM: CurrentBrightness = value + MonitorVM->>MonitorVM: SetBrightnessAsync(value, immediate=false) + MonitorVM->>Debouncer: Debounce(300ms) + Note over Debouncer: Resets timer on each call + end + + User->>Slider: Releases slider + + Note over Debouncer: 300ms elapsed, no new input + Debouncer->>MonitorVM: Execute debounced action + + MonitorVM->>MonitorVM: ApplyBrightnessToHardwareAsync() + MonitorVM->>MonitorManager: SetBrightnessAsync(monitor, finalValue) + + MonitorManager->>Controller: SetBrightnessAsync(monitor, value) + + Controller->>Controller: SetVcpFeatureAsync(VcpCodeBrightness) + Controller->>Monitor: SetVCPFeature(0x10, value) + Monitor-->>Controller: OK + + Controller-->>MonitorManager: MonitorOperationResult + MonitorManager-->>MonitorVM: Success/Failure + + MonitorVM->>StateManager: UpdateMonitorParameter("Brightness", value) + + Note over StateManager: Debounced save (2 seconds) + StateManager->>StateManager: Schedule file write + + Note over StateManager: After 2s idle + StateManager->>StateManager: SaveToFile(monitor_state.json) +``` + +--- + +### Sequence: Module Enable/Disable Lifecycle + +```mermaid +sequenceDiagram + participant Runner as PowerToys Runner + participant ModuleInterface as PowerDisplayModule (C++) + participant PowerDisplayApp as PowerDisplay.exe + participant MonitorManager + participant StateManager as MonitorStateManager + participant EventHandles as Windows Events + + Note over Runner: User enables PowerDisplay + Runner->>ModuleInterface: enable() + + ModuleInterface->>ModuleInterface: m_enabled = true + ModuleInterface->>ModuleInterface: Trace::EnablePowerDisplay(true) + + ModuleInterface->>ModuleInterface: is_process_running() + alt Process not running + ModuleInterface->>PowerDisplayApp: ShellExecuteExW("PowerToys.PowerDisplay.exe", pid) + PowerDisplayApp->>PowerDisplayApp: Initialize WinUI 3 App + PowerDisplayApp->>PowerDisplayApp: RegisterSingletonInstance() + PowerDisplayApp->>MonitorManager: DiscoverMonitorsAsync() + + alt RestoreSettingsOnStartup enabled + PowerDisplayApp->>StateManager: GetMonitorParameters(monitorId) + StateManager-->>PowerDisplayApp: Saved brightness, contrast, etc. + PowerDisplayApp->>MonitorManager: SetBrightnessAsync(savedValue) + PowerDisplayApp->>MonitorManager: SetContrastAsync(savedValue) + Note over PowerDisplayApp: Restore all saved settings to hardware + end + + PowerDisplayApp->>PowerDisplayApp: Start event listeners + PowerDisplayApp->>EventHandles: SetEvent("Ready") + end + + ModuleInterface->>ModuleInterface: m_hProcess = sei.hProcess + + Note over Runner: User presses hotkey + Runner->>ModuleInterface: on_hotkey() + ModuleInterface->>EventHandles: SetEvent(ToggleEvent) + EventHandles->>PowerDisplayApp: Toggle visibility + + Note over Runner: User disables PowerDisplay + Runner->>ModuleInterface: disable() + + ModuleInterface->>EventHandles: ResetEvent(InvokeEvent) + ModuleInterface->>EventHandles: SetEvent(TerminateEvent) + + PowerDisplayApp->>PowerDisplayApp: Receive terminate signal + PowerDisplayApp->>MonitorManager: Dispose() + PowerDisplayApp->>PowerDisplayApp: Application.Exit() + + ModuleInterface->>ModuleInterface: CloseHandle(m_hProcess) + ModuleInterface->>ModuleInterface: m_enabled = false + ModuleInterface->>ModuleInterface: Trace::EnablePowerDisplay(false) +``` + +--- + +## Future Considerations + +### Already Implemented + +- **Monitor Hot-Plug**: `DisplayChangeWatcher` uses WinRT DeviceWatcher + DisplayMonitor API with 1-second debouncing +- **Display Rotation**: `DisplayRotationService` uses Windows ChangeDisplaySettingsEx API +- **LightSwitch Integration**: Automatic profile application on theme changes via `LightSwitchService` +- **Monitor Identification**: Overlay windows showing monitor numbers via `IdentifyWindow` +- **Mirror Mode Support**: Correct orientation sync for multiple monitors sharing the same GDI device name + +### Potential Future Enhancements + +1. **Advanced Color Management**: Integration with Windows Color Management APIs (HDR, ICC profiles) +2. **PIP/PBP Control**: Picture-in-Picture and Picture-by-Picture configuration (VcpCapabilities already parses window capabilities) +3. **Power State Control**: Monitor power on/off via VCP code 0xD6 + +--- + +## References + +- [VESA DDC/CI Standard](https://vesa.org/vesa-standards/) +- [MCCS (Monitor Control Command Set) Specification](https://vesa.org/vesa-standards/) +- [Microsoft High-Level Monitor Configuration API](https://learn.microsoft.com/en-us/windows/win32/monitor/high-level-monitor-configuration-api) +- [WMI Reference](https://learn.microsoft.com/en-us/windows/win32/wmisdk/wmi-reference) +- [WmiMonitorBrightness Class](https://learn.microsoft.com/en-us/windows/win32/wmicoreprov/wmimonitorbrightness) +- [PowerToys Architecture Documentation](../../core/architecture.md) diff --git a/doc/devdocs/modules/powerdisplay/mccsParserDesign.md b/doc/devdocs/modules/powerdisplay/mccsParserDesign.md new file mode 100644 index 0000000000..128407308c --- /dev/null +++ b/doc/devdocs/modules/powerdisplay/mccsParserDesign.md @@ -0,0 +1,223 @@ +# MCCS Capabilities String Parser - Recursive Descent Design + +## Overview + +This document describes the recursive descent parser implementation for DDC/CI MCCS (Monitor Control Command Set) capabilities strings. + +### Attention! +This document and the code implement are generated by Copilot. + +## Grammar Definition (BNF) + +```bnf +capabilities ::= ['('] segment* [')'] +segment ::= identifier '(' segment_content ')' +segment_content ::= text | vcp_entries | hex_list +vcp_entries ::= vcp_entry* +vcp_entry ::= hex_byte [ '(' hex_list ')' ] +hex_list ::= hex_byte* +hex_byte ::= [0-9A-Fa-f]{2} +identifier ::= [a-z_A-Z]+ +text ::= [^()]+ +``` + +## Example Input + +``` +(prot(monitor)type(lcd)model(PD3220U)cmds(01 02 03 07)vcp(10 12 14(04 05 06) 16 60(11 12 0F) DC DF)mccs_ver(2.2)vcpname(F0(Custom Setting))) +``` + +## Parser Architecture + +### Component Hierarchy + +``` +MccsCapabilitiesParser (main parser) +β”œβ”€β”€ ParseCapabilities() β†’ MccsParseResult +β”œβ”€β”€ ParseSegment() β†’ ParsedSegment? +β”œβ”€β”€ ParseBalancedContent() β†’ string +β”œβ”€β”€ ParseIdentifier() β†’ ReadOnlySpan +β”œβ”€β”€ ApplySegment() β†’ void +β”‚ β”œβ”€β”€ ParseHexList() β†’ List +β”‚ β”œβ”€β”€ ParseVcpEntries() β†’ Dictionary +β”‚ └── ParseVcpNames() β†’ void +β”‚ +β”œβ”€β”€ VcpEntryParser (sub-parser for vcp() content) +β”‚ └── TryParseEntry() β†’ VcpEntry +β”‚ +β”œβ”€β”€ VcpNameParser (sub-parser for vcpname() content) +β”‚ └── TryParseEntry() β†’ (byte code, string name) +β”‚ +└── WindowParser (sub-parser for windowN() content) + β”œβ”€β”€ Parse() β†’ WindowCapability + └── ParseSubSegment() β†’ (name, content)? +``` + +### Design Principles + +1. **ref struct for Zero Allocation** + - Main parser uses `ref struct` to avoid heap allocation + - Works with `ReadOnlySpan` for efficient string slicing + - No intermediate string allocations during parsing + +2. **Recursive Descent Pattern** + - Each grammar rule has a corresponding parse method + - Methods call each other recursively for nested structures + - Single-character lookahead via `Peek()` + +3. **Error Recovery** + - Errors are accumulated, not thrown + - Parser attempts to continue after errors + - Returns partial results when possible + +4. **Sub-parsers for Specialized Content** + - `VcpEntryParser` for VCP code entries + - `VcpNameParser` for custom VCP names + - Each sub-parser handles its own grammar subset + +## Parse Methods Detail + +### ParseCapabilities() +Entry point. Handles optional outer parentheses and iterates through segments. + +```csharp +private MccsParseResult ParseCapabilities() +{ + // Handle optional outer parens + // while (!IsAtEnd()) { ParseSegment() } + // Return result with accumulated errors +} +``` + +### ParseSegment() +Parses a single `identifier(content)` segment. + +```csharp +private ParsedSegment? ParseSegment() +{ + // 1. ParseIdentifier() + // 2. Expect '(' + // 3. ParseBalancedContent() + // 4. Expect ')' +} +``` + +### ParseBalancedContent() +Extracts content between balanced parentheses, handling nested parens. + +```csharp +private string ParseBalancedContent() +{ + int depth = 1; + while (depth > 0) { + if (char == '(') depth++; + if (char == ')') depth--; + } +} +``` + +### ParseVcpEntries() +Delegates to `VcpEntryParser` for the specialized VCP entry grammar. + +```csharp +vcp_entry ::= hex_byte [ '(' hex_list ')' ] + +Examples: +- "10" β†’ code=0x10, values=[] +- "14(04 05 06)" β†’ code=0x14, values=[4, 5, 6] +- "60(11 12 0F)" β†’ code=0x60, values=[0x11, 0x12, 0x0F] +``` + +## Comparison with Other Approaches + +| Approach | Pros | Cons | +|----------|------|------| +| **Recursive Descent** (this) | Clear structure, handles nesting, extensible | More code | +| **Regex** (DDCSharp) | Concise | Hard to debug, limited nesting | +| **Mixed** (original) | Pragmatic | Inconsistent, hard to maintain | + +## Performance Characteristics + +- **Time Complexity**: O(n) where n = input length +- **Space Complexity**: O(1) for parsing + O(m) for output where m = number of VCP codes +- **Allocations**: Minimal - only for output structures + +## Supported Segments + +| Segment | Description | Parser | +|---------|-------------|--------| +| `prot(...)` | Protocol type | Direct assignment | +| `type(...)` | Display type (lcd/crt) | Direct assignment | +| `model(...)` | Model name | Direct assignment | +| `cmds(...)` | Supported commands | ParseHexList | +| `vcp(...)` | VCP code entries | VcpEntryParser | +| `mccs_ver(...)` | MCCS version | Direct assignment | +| `vcpname(...)` | Custom VCP names | VcpNameParser | +| `windowN(...)` | PIP/PBP window capabilities | WindowParser | + +### Window Segment Format + +The `windowN` segment (where N is 1, 2, 3, etc.) describes PIP/PBP window capabilities: + +``` +window1(type(PIP) area(25 25 1895 1175) max(640 480) min(10 10) window(10)) +``` + +| Sub-field | Format | Description | +|-----------|--------|-------------| +| `type` | `type(PIP)` or `type(PBP)` | Window type (Picture-in-Picture or Picture-by-Picture) | +| `area` | `area(x1 y1 x2 y2)` | Window area coordinates in pixels | +| `max` | `max(width height)` | Maximum window dimensions | +| `min` | `min(width height)` | Minimum window dimensions | +| `window` | `window(id)` | Window identifier | + +All sub-fields are optional; missing fields default to zero values. + +## Error Handling + +```csharp +public readonly struct ParseError +{ + public int Position { get; } // Character position + public string Message { get; } // Human-readable error +} + +public sealed class MccsParseResult +{ + public VcpCapabilities Capabilities { get; } + public IReadOnlyList Errors { get; } + public bool HasErrors => Errors.Count > 0; + public bool IsValid => !HasErrors && Capabilities.SupportedVcpCodes.Count > 0; +} +``` + +## Usage Example + +```csharp +// Parse capabilities string +var result = MccsCapabilitiesParser.Parse(capabilitiesString); + +if (result.IsValid) +{ + var caps = result.Capabilities; + Console.WriteLine($"Model: {caps.Model}"); + Console.WriteLine($"MCCS Version: {caps.MccsVersion}"); + Console.WriteLine($"VCP Codes: {caps.SupportedVcpCodes.Count}"); +} + +if (result.HasErrors) +{ + foreach (var error in result.Errors) + { + Console.WriteLine($"Parse error at {error.Position}: {error.Message}"); + } +} +``` + +## Edge Cases Handled + +1. **Missing outer parentheses** (Apple Cinema Display) +2. **No spaces between hex bytes** (`010203` vs `01 02 03`) +3. **Nested parentheses** in VCP values +4. **Unknown segments** (logged but not fatal) +5. **Malformed input** (partial results returned) diff --git a/doc/devdocs/readme.md b/doc/devdocs/readme.md index f70299f8d7..f07b53f14e 100644 --- a/doc/devdocs/readme.md +++ b/doc/devdocs/readme.md @@ -68,6 +68,7 @@ Once you've discussed your proposed feature/fix/etc. with a team member, and an - Add the `In progress` label to the issue, if not already present. Also add a `Cost-Small/Medium/Large` estimate and make sure all appropriate labels are set. - If you are a community contributor, you will not be able to add labels to the issue; in that case just add a comment saying that you have started work on the issue and try to give an estimate for the delivery date. - If the work item has a medium/large cost, using the markdown task list, list each sub item and update the list with a check mark after completing each sub item. +- **Before opening a PR, ensure your changes build successfully locally and functionality tests pass.** This is especially important for AI-assisted (vibe coding) contributionsβ€”always verify AI-generated code works as intended. Exploratory PRs or draft PRs for discussion are exceptions. - When opening a PR, follow the PR template. - When you'd like the team to take a look (even if the work is not yet fully complete) mark the PR as 'Ready For Review' so that the team can review your work and provide comments, suggestions, and request changes. It may take several cycles, but the end result will be solid, testable, conformant code that is safe for us to merge. - When the PR is approved, let the owner of the PR merge it. For community contributions, the reviewer who approved the PR can also merge it. @@ -79,7 +80,7 @@ Once you've discussed your proposed feature/fix/etc. with a team member, and an ### Prerequisites for Compiling PowerToys 1. Windows 10 April 2018 Update (version 1803) or newer -1. Visual Studio Community/Professional/Enterprise 2022 17.4 or newer +1. Visual Studio Community/Professional/Enterprise 2022 17.4 or newer, or Visual Studio 2026 1. A local clone of the PowerToys repository 1. Enable long paths in Windows (see [Enable Long Paths](https://docs.microsoft.com/windows/win32/fileio/maximum-file-path-limitation#enabling-long-paths-in-windows-10-version-1607-and-later) for details) diff --git a/installer/PowerToysSetupCustomActionsVNext/CustomAction.cpp b/installer/PowerToysSetupCustomActionsVNext/CustomAction.cpp index 40c3d5b0e8..43919ecaf1 100644 --- a/installer/PowerToysSetupCustomActionsVNext/CustomAction.cpp +++ b/installer/PowerToysSetupCustomActionsVNext/CustomAction.cpp @@ -1549,7 +1549,7 @@ UINT __stdcall TerminateProcessesCA(MSIHANDLE hInstall) } processes.resize(bytes / sizeof(processes[0])); - std::array processesToTerminate = { + std::array processesToTerminate = { L"PowerToys.PowerLauncher.exe", L"PowerToys.Settings.exe", L"PowerToys.AdvancedPaste.exe", @@ -1565,6 +1565,7 @@ UINT __stdcall TerminateProcessesCA(MSIHANDLE hInstall) L"PowerToys.PowerRename.exe", L"PowerToys.ImageResizer.exe", L"PowerToys.LightSwitchService.exe", + L"PowerToys.PowerDisplay.exe", L"PowerToys.GcodeThumbnailProvider.exe", L"PowerToys.BgcodeThumbnailProvider.exe", L"PowerToys.PdfThumbnailProvider.exe", diff --git a/installer/PowerToysSetupCustomActionsVNext/PowerToysSetupCustomActionsVNext.vcxproj b/installer/PowerToysSetupCustomActionsVNext/PowerToysSetupCustomActionsVNext.vcxproj index ae50cdcedb..9e07a41049 100644 --- a/installer/PowerToysSetupCustomActionsVNext/PowerToysSetupCustomActionsVNext.vcxproj +++ b/installer/PowerToysSetupCustomActionsVNext/PowerToysSetupCustomActionsVNext.vcxproj @@ -14,13 +14,13 @@ DynamicLibrary Unicode - v143 + DynamicLibrary Unicode true - v143 + diff --git a/installer/PowerToysSetupVNext/Directory.Build.props b/installer/PowerToysSetupVNext/Directory.Build.props index 505e3cf844..9bb1d8b75c 100644 --- a/installer/PowerToysSetupVNext/Directory.Build.props +++ b/installer/PowerToysSetupVNext/Directory.Build.props @@ -1,5 +1,5 @@ - + obj\Installer\ diff --git a/installer/PowerToysSetupVNext/PowerDisplay.wxs b/installer/PowerToysSetupVNext/PowerDisplay.wxs new file mode 100644 index 0000000000..5cfe23661c --- /dev/null +++ b/installer/PowerToysSetupVNext/PowerDisplay.wxs @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/installer/PowerToysSetupVNext/PowerToysInstallerVNext.wixproj b/installer/PowerToysSetupVNext/PowerToysInstallerVNext.wixproj index a7a9744e87..4000503edf 100644 --- a/installer/PowerToysSetupVNext/PowerToysInstallerVNext.wixproj +++ b/installer/PowerToysSetupVNext/PowerToysInstallerVNext.wixproj @@ -47,6 +47,7 @@ call powershell.exe -NonInteractive -executionpolicy Unrestricted -File $(MSBuil call move /Y ..\..\..\NewPlus.wxs.bk ..\..\..\NewPlus.wxs call move /Y ..\..\..\Peek.wxs.bk ..\..\..\Peek.wxs call move /Y ..\..\..\PowerRename.wxs.bk ..\..\..\PowerRename.wxs + call move /Y ..\..\..\PowerDisplay.wxs.bk ..\..\..\PowerDisplay.wxs call move /Y ..\..\..\Product.wxs.bk ..\..\..\Product.wxs call move /Y ..\..\..\RegistryPreview.wxs.bk ..\..\..\RegistryPreview.wxs call move /Y ..\..\..\Resources.wxs.bk ..\..\..\Resources.wxs @@ -123,6 +124,7 @@ call powershell.exe -NonInteractive -executionpolicy Unrestricted -File $(MSBuil + diff --git a/installer/PowerToysSetupVNext/Product.wxs b/installer/PowerToysSetupVNext/Product.wxs index a5615870f9..1a5f8010f7 100644 --- a/installer/PowerToysSetupVNext/Product.wxs +++ b/installer/PowerToysSetupVNext/Product.wxs @@ -53,6 +53,7 @@ + @@ -146,7 +147,7 @@ - + - net8.0-windows10.0.19041.0 + net8.0-windows10.0.26100.0 diff --git a/src/PackageIdentity/PackageIdentity.vcxproj b/src/PackageIdentity/PackageIdentity.vcxproj index f8d34f5650..8e8c9ce65a 100644 --- a/src/PackageIdentity/PackageIdentity.vcxproj +++ b/src/PackageIdentity/PackageIdentity.vcxproj @@ -55,26 +55,26 @@ Utility true - v143 + Utility false - v143 + true Utility true - v143 + Utility false - v143 + true diff --git a/src/Update/PowerToys.Update.vcxproj b/src/Update/PowerToys.Update.vcxproj index 172a7027a6..f60273324c 100644 --- a/src/Update/PowerToys.Update.vcxproj +++ b/src/Update/PowerToys.Update.vcxproj @@ -12,7 +12,7 @@ - v143 + diff --git a/src/common/COMUtils/COMUtils.vcxproj b/src/common/COMUtils/COMUtils.vcxproj index f582df593b..42c3e7802b 100644 --- a/src/common/COMUtils/COMUtils.vcxproj +++ b/src/common/COMUtils/COMUtils.vcxproj @@ -10,7 +10,7 @@ StaticLibrary - v143 + diff --git a/src/common/CalculatorEngineCommon/CalculatorEngineCommon.vcxproj b/src/common/CalculatorEngineCommon/CalculatorEngineCommon.vcxproj index 43f4749892..bce77c8ee1 100644 --- a/src/common/CalculatorEngineCommon/CalculatorEngineCommon.vcxproj +++ b/src/common/CalculatorEngineCommon/CalculatorEngineCommon.vcxproj @@ -40,7 +40,7 @@ DynamicLibrary - v143 + Unicode false diff --git a/src/common/CalculatorEngineCommon/ExprtkEvaluator.cpp b/src/common/CalculatorEngineCommon/ExprtkEvaluator.cpp index dbc2120b24..eb04c18783 100644 --- a/src/common/CalculatorEngineCommon/ExprtkEvaluator.cpp +++ b/src/common/CalculatorEngineCommon/ExprtkEvaluator.cpp @@ -3,9 +3,27 @@ #include #include #include +#include +#include namespace ExprtkCalculator::internal { + static double factorial(const double n) + { + // Only allow non-negative integers + if (n < 0.0 || std::floor(n) != n) + { + return std::numeric_limits::quiet_NaN(); + } + return std::tgamma(n + 1.0); + } + + static double sign(const double n) + { + if (n > 0.0) return 1.0; + if (n < 0.0) return -1.0; + return 0.0; + } std::wstring ToWStringFullPrecision(double value) { @@ -25,6 +43,9 @@ namespace ExprtkCalculator::internal symbol_table.add_constant(name, value); } + symbol_table.add_function("factorial", factorial); + symbol_table.add_function("sign", sign); + exprtk::expression expression; expression.register_symbol_table(symbol_table); diff --git a/src/common/Common.UI/SettingsDeepLink.cs b/src/common/Common.UI/SettingsDeepLink.cs index 5233c0d668..fedf5480e3 100644 --- a/src/common/Common.UI/SettingsDeepLink.cs +++ b/src/common/Common.UI/SettingsDeepLink.cs @@ -45,6 +45,7 @@ namespace Common.UI NewPlus, CmdPal, ZoomIt, + PowerDisplay, } private static string SettingsWindowNameToString(SettingsWindow value) @@ -115,6 +116,8 @@ namespace Common.UI return "CmdPal"; case SettingsWindow.ZoomIt: return "ZoomIt"; + case SettingsWindow.PowerDisplay: + return "PowerDisplay"; default: { return string.Empty; diff --git a/src/common/Display/Display.vcxproj b/src/common/Display/Display.vcxproj index 87b74ba534..c5802f0ad8 100644 --- a/src/common/Display/Display.vcxproj +++ b/src/common/Display/Display.vcxproj @@ -1,5 +1,6 @@ + 16.0 {CABA8DFB-823B-4BF2-93AC-3F31984150D9} @@ -10,7 +11,7 @@ StaticLibrary - v143 + @@ -39,5 +40,18 @@ + + + + + + + + + This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. + + + + \ No newline at end of file diff --git a/src/common/Display/packages.config b/src/common/Display/packages.config new file mode 100644 index 0000000000..2e5039eb82 --- /dev/null +++ b/src/common/Display/packages.config @@ -0,0 +1,4 @@ + + + + diff --git a/src/common/GPOWrapper/GPOWrapper.cpp b/src/common/GPOWrapper/GPOWrapper.cpp index 2b256cd926..1132df9599 100644 --- a/src/common/GPOWrapper/GPOWrapper.cpp +++ b/src/common/GPOWrapper/GPOWrapper.cpp @@ -32,6 +32,10 @@ namespace winrt::PowerToys::GPOWrapper::implementation { return static_cast(powertoys_gpo::getConfiguredLightSwitchEnabledValue()); } + GpoRuleConfigured GPOWrapper::GetConfiguredPowerDisplayEnabledValue() + { + return static_cast(powertoys_gpo::getConfiguredPowerDisplayEnabledValue()); + } GpoRuleConfigured GPOWrapper::GetConfiguredFancyZonesEnabledValue() { return static_cast(powertoys_gpo::getConfiguredFancyZonesEnabledValue()); diff --git a/src/common/GPOWrapper/GPOWrapper.h b/src/common/GPOWrapper/GPOWrapper.h index e57cccccd9..aceb3bf756 100644 --- a/src/common/GPOWrapper/GPOWrapper.h +++ b/src/common/GPOWrapper/GPOWrapper.h @@ -14,6 +14,7 @@ namespace winrt::PowerToys::GPOWrapper::implementation static GpoRuleConfigured GetConfiguredColorPickerEnabledValue(); static GpoRuleConfigured GetConfiguredCropAndLockEnabledValue(); static GpoRuleConfigured GetConfiguredLightSwitchEnabledValue(); + static GpoRuleConfigured GetConfiguredPowerDisplayEnabledValue(); static GpoRuleConfigured GetConfiguredFancyZonesEnabledValue(); static GpoRuleConfigured GetConfiguredFileLocksmithEnabledValue(); static GpoRuleConfigured GetConfiguredSvgPreviewEnabledValue(); diff --git a/src/common/GPOWrapper/GPOWrapper.idl b/src/common/GPOWrapper/GPOWrapper.idl index 06d035aa35..58c35cd977 100644 --- a/src/common/GPOWrapper/GPOWrapper.idl +++ b/src/common/GPOWrapper/GPOWrapper.idl @@ -18,6 +18,7 @@ namespace PowerToys static GpoRuleConfigured GetConfiguredColorPickerEnabledValue(); static GpoRuleConfigured GetConfiguredCropAndLockEnabledValue(); static GpoRuleConfigured GetConfiguredLightSwitchEnabledValue(); + static GpoRuleConfigured GetConfiguredPowerDisplayEnabledValue(); static GpoRuleConfigured GetConfiguredFancyZonesEnabledValue(); static GpoRuleConfigured GetConfiguredFileLocksmithEnabledValue(); static GpoRuleConfigured GetConfiguredSvgPreviewEnabledValue(); diff --git a/src/common/GPOWrapper/GPOWrapper.vcxproj b/src/common/GPOWrapper/GPOWrapper.vcxproj index c77620493d..4ebc0f0cc9 100644 --- a/src/common/GPOWrapper/GPOWrapper.vcxproj +++ b/src/common/GPOWrapper/GPOWrapper.vcxproj @@ -19,7 +19,7 @@ DynamicLibrary - v143 + Unicode false true diff --git a/src/common/ManagedCommon/ModuleType.cs b/src/common/ManagedCommon/ModuleType.cs index 8461b4a6d8..548276f725 100644 --- a/src/common/ManagedCommon/ModuleType.cs +++ b/src/common/ManagedCommon/ModuleType.cs @@ -30,6 +30,7 @@ namespace ManagedCommon PowerRename, PowerLauncher, PowerAccent, + PowerDisplay, RegistryPreview, MeasureTool, ShortcutGuide, diff --git a/src/common/SettingsAPI/SettingsAPI.vcxproj b/src/common/SettingsAPI/SettingsAPI.vcxproj index d09e33a334..0355836a00 100644 --- a/src/common/SettingsAPI/SettingsAPI.vcxproj +++ b/src/common/SettingsAPI/SettingsAPI.vcxproj @@ -12,7 +12,7 @@ StaticLibrary - v143 + diff --git a/src/common/Telemetry/EtwTrace/EtwTrace.vcxproj b/src/common/Telemetry/EtwTrace/EtwTrace.vcxproj index 17b3be7a26..b213541eda 100644 --- a/src/common/Telemetry/EtwTrace/EtwTrace.vcxproj +++ b/src/common/Telemetry/EtwTrace/EtwTrace.vcxproj @@ -11,7 +11,7 @@ StaticLibrary - v143 + diff --git a/src/common/Themes/Themes.vcxproj b/src/common/Themes/Themes.vcxproj index f9772c874f..be37d7d15c 100644 --- a/src/common/Themes/Themes.vcxproj +++ b/src/common/Themes/Themes.vcxproj @@ -11,7 +11,7 @@ StaticLibrary - v143 + diff --git a/src/common/UnitTests-CommonLib/UnitTests-CommonLib.vcxproj b/src/common/UnitTests-CommonLib/UnitTests-CommonLib.vcxproj index 28041207fa..e0842faf83 100644 --- a/src/common/UnitTests-CommonLib/UnitTests-CommonLib.vcxproj +++ b/src/common/UnitTests-CommonLib/UnitTests-CommonLib.vcxproj @@ -13,7 +13,7 @@ DynamicLibrary false - v143 + $(SolutionDir)$(Platform)\$(Configuration)\tests\UnitTestsCommonLib\ diff --git a/src/common/UnitTests-CommonUtils/AppMutex.Tests.cpp b/src/common/UnitTests-CommonUtils/AppMutex.Tests.cpp new file mode 100644 index 0000000000..cea3f0a63d --- /dev/null +++ b/src/common/UnitTests-CommonUtils/AppMutex.Tests.cpp @@ -0,0 +1,120 @@ +#include "pch.h" +#include "TestHelpers.h" +#include + +using namespace Microsoft::VisualStudio::CppUnitTestFramework; + +namespace UnitTestsCommonUtils +{ + TEST_CLASS(AppMutexTests) + { + public: + TEST_METHOD(CreateAppMutex_ValidName_ReturnsHandle) + { + std::wstring mutexName = L"TestMutex_" + std::to_wstring(GetCurrentProcessId()) + L"_1"; + auto handle = createAppMutex(mutexName); + Assert::IsNotNull(handle.get()); + } + + TEST_METHOD(CreateAppMutex_SameName_ReturnsExistingHandle) + { + std::wstring mutexName = L"TestMutex_" + std::to_wstring(GetCurrentProcessId()) + L"_2"; + + auto handle1 = createAppMutex(mutexName); + Assert::IsNotNull(handle1.get()); + + auto handle2 = createAppMutex(mutexName); + Assert::IsNull(handle2.get()); + } + + TEST_METHOD(CreateAppMutex_DifferentNames_ReturnsDifferentHandles) + { + std::wstring mutexName1 = L"TestMutex_" + std::to_wstring(GetCurrentProcessId()) + L"_A"; + std::wstring mutexName2 = L"TestMutex_" + std::to_wstring(GetCurrentProcessId()) + L"_B"; + + auto handle1 = createAppMutex(mutexName1); + auto handle2 = createAppMutex(mutexName2); + + Assert::IsNotNull(handle1.get()); + Assert::IsNotNull(handle2.get()); + Assert::AreNotEqual(handle1.get(), handle2.get()); + } + + TEST_METHOD(CreateAppMutex_EmptyName_ReturnsHandle) + { + // Empty name creates unnamed mutex + auto handle = createAppMutex(L""); + // CreateMutexW with empty string should still work + Assert::IsTrue(true); + // Test passes regardless - just checking it doesn't crash + Assert::IsTrue(true); + } + + TEST_METHOD(CreateAppMutex_LongName_ReturnsHandle) + { + // Create a long mutex name + std::wstring mutexName = L"TestMutex_" + std::to_wstring(GetCurrentProcessId()) + L"_"; + for (int i = 0; i < 50; ++i) + { + mutexName += L"LongNameSegment"; + } + + auto handle = createAppMutex(mutexName); + // Long names might fail, but shouldn't crash + Assert::IsTrue(true); + } + + TEST_METHOD(CreateAppMutex_SpecialCharacters_ReturnsHandle) + { + std::wstring mutexName = L"TestMutex_" + std::to_wstring(GetCurrentProcessId()) + L"_Special!@#$%"; + + auto handle = createAppMutex(mutexName); + // Some special characters might not be valid in mutex names + Assert::IsTrue(true); + } + + TEST_METHOD(CreateAppMutex_GlobalPrefix_ReturnsHandle) + { + // Global prefix for cross-session mutex + std::wstring mutexName = L"Global\\TestMutex_" + std::to_wstring(GetCurrentProcessId()); + + auto handle = createAppMutex(mutexName); + // Might require elevation, but shouldn't crash + Assert::IsTrue(true); + } + + TEST_METHOD(CreateAppMutex_LocalPrefix_ReturnsHandle) + { + std::wstring mutexName = L"Local\\TestMutex_" + std::to_wstring(GetCurrentProcessId()); + + auto handle = createAppMutex(mutexName); + Assert::IsNotNull(handle.get()); + } + + TEST_METHOD(CreateAppMutex_MultipleCalls_AllSucceed) + { + std::vector handles; + for (int i = 0; i < 10; ++i) + { + std::wstring mutexName = L"TestMutex_" + std::to_wstring(GetCurrentProcessId()) + + L"_Multi_" + std::to_wstring(i); + auto handle = createAppMutex(mutexName); + Assert::IsNotNull(handle.get()); + handles.push_back(std::move(handle)); + } + } + + TEST_METHOD(CreateAppMutex_ReleaseAndRecreate_Works) + { + std::wstring mutexName = L"TestMutex_" + std::to_wstring(GetCurrentProcessId()) + L"_Recreate"; + + auto handle1 = createAppMutex(mutexName); + Assert::IsNotNull(handle1.get()); + handle1.reset(); + + // After closing, should be able to create again + auto handle2 = createAppMutex(mutexName); + Assert::IsNotNull(handle2.get()); + } + }; +} diff --git a/src/common/UnitTests-CommonUtils/ColorUtils.Tests.cpp b/src/common/UnitTests-CommonUtils/ColorUtils.Tests.cpp new file mode 100644 index 0000000000..416c646de3 --- /dev/null +++ b/src/common/UnitTests-CommonUtils/ColorUtils.Tests.cpp @@ -0,0 +1,220 @@ +#include "pch.h" +#include "TestHelpers.h" +#include + +using namespace Microsoft::VisualStudio::CppUnitTestFramework; + +namespace UnitTestsCommonUtils +{ + TEST_CLASS(ColorUtilsTests) + { + public: + // checkValidRGB tests + TEST_METHOD(CheckValidRGB_ValidBlack_ReturnsTrue) + { + uint8_t r, g, b; + bool result = checkValidRGB(L"#000000", &r, &g, &b); + Assert::IsTrue(result); + Assert::AreEqual(static_cast(0), r); + Assert::AreEqual(static_cast(0), g); + Assert::AreEqual(static_cast(0), b); + } + + TEST_METHOD(CheckValidRGB_ValidWhite_ReturnsTrue) + { + uint8_t r, g, b; + bool result = checkValidRGB(L"#FFFFFF", &r, &g, &b); + Assert::IsTrue(result); + Assert::AreEqual(static_cast(255), r); + Assert::AreEqual(static_cast(255), g); + Assert::AreEqual(static_cast(255), b); + } + + TEST_METHOD(CheckValidRGB_ValidRed_ReturnsTrue) + { + uint8_t r, g, b; + bool result = checkValidRGB(L"#FF0000", &r, &g, &b); + Assert::IsTrue(result); + Assert::AreEqual(static_cast(255), r); + Assert::AreEqual(static_cast(0), g); + Assert::AreEqual(static_cast(0), b); + } + + TEST_METHOD(CheckValidRGB_ValidGreen_ReturnsTrue) + { + uint8_t r, g, b; + bool result = checkValidRGB(L"#00FF00", &r, &g, &b); + Assert::IsTrue(result); + Assert::AreEqual(static_cast(0), r); + Assert::AreEqual(static_cast(255), g); + Assert::AreEqual(static_cast(0), b); + } + + TEST_METHOD(CheckValidRGB_ValidBlue_ReturnsTrue) + { + uint8_t r, g, b; + bool result = checkValidRGB(L"#0000FF", &r, &g, &b); + Assert::IsTrue(result); + Assert::AreEqual(static_cast(0), r); + Assert::AreEqual(static_cast(0), g); + Assert::AreEqual(static_cast(255), b); + } + + TEST_METHOD(CheckValidRGB_ValidMixed_ReturnsTrue) + { + uint8_t r, g, b; + bool result = checkValidRGB(L"#AB12CD", &r, &g, &b); + Assert::IsTrue(result); + Assert::AreEqual(static_cast(0xAB), r); + Assert::AreEqual(static_cast(0x12), g); + Assert::AreEqual(static_cast(0xCD), b); + } + + TEST_METHOD(CheckValidRGB_MissingHash_ReturnsFalse) + { + uint8_t r, g, b; + bool result = checkValidRGB(L"FFFFFF", &r, &g, &b); + Assert::IsFalse(result); + } + + TEST_METHOD(CheckValidRGB_TooShort_ReturnsFalse) + { + uint8_t r, g, b; + bool result = checkValidRGB(L"#FFF", &r, &g, &b); + Assert::IsFalse(result); + } + + TEST_METHOD(CheckValidRGB_TooLong_ReturnsFalse) + { + uint8_t r, g, b; + bool result = checkValidRGB(L"#FFFFFFFF", &r, &g, &b); + Assert::IsFalse(result); + } + + TEST_METHOD(CheckValidRGB_InvalidChars_ReturnsFalse) + { + uint8_t r, g, b; + bool result = checkValidRGB(L"#GGHHII", &r, &g, &b); + Assert::IsFalse(result); + } + + TEST_METHOD(CheckValidRGB_LowercaseInvalid_ReturnsFalse) + { + uint8_t r, g, b; + bool result = checkValidRGB(L"#ffffff", &r, &g, &b); + Assert::IsFalse(result); + } + + TEST_METHOD(CheckValidRGB_EmptyString_ReturnsFalse) + { + uint8_t r, g, b; + bool result = checkValidRGB(L"", &r, &g, &b); + Assert::IsFalse(result); + } + + TEST_METHOD(CheckValidRGB_OnlyHash_ReturnsFalse) + { + uint8_t r, g, b; + bool result = checkValidRGB(L"#", &r, &g, &b); + Assert::IsFalse(result); + } + + // checkValidARGB tests + TEST_METHOD(CheckValidARGB_ValidBlackOpaque_ReturnsTrue) + { + uint8_t a, r, g, b; + bool result = checkValidARGB(L"#FF000000", &a, &r, &g, &b); + Assert::IsTrue(result); + Assert::AreEqual(static_cast(255), a); + Assert::AreEqual(static_cast(0), r); + Assert::AreEqual(static_cast(0), g); + Assert::AreEqual(static_cast(0), b); + } + + TEST_METHOD(CheckValidARGB_ValidWhiteOpaque_ReturnsTrue) + { + uint8_t a, r, g, b; + bool result = checkValidARGB(L"#FFFFFFFF", &a, &r, &g, &b); + Assert::IsTrue(result); + Assert::AreEqual(static_cast(255), a); + Assert::AreEqual(static_cast(255), r); + Assert::AreEqual(static_cast(255), g); + Assert::AreEqual(static_cast(255), b); + } + + TEST_METHOD(CheckValidARGB_ValidTransparent_ReturnsTrue) + { + uint8_t a, r, g, b; + bool result = checkValidARGB(L"#00FFFFFF", &a, &r, &g, &b); + Assert::IsTrue(result); + Assert::AreEqual(static_cast(0), a); + Assert::AreEqual(static_cast(255), r); + Assert::AreEqual(static_cast(255), g); + Assert::AreEqual(static_cast(255), b); + } + + TEST_METHOD(CheckValidARGB_ValidSemiTransparent_ReturnsTrue) + { + uint8_t a, r, g, b; + bool result = checkValidARGB(L"#80FF0000", &a, &r, &g, &b); + Assert::IsTrue(result); + Assert::AreEqual(static_cast(0x80), a); + Assert::AreEqual(static_cast(255), r); + Assert::AreEqual(static_cast(0), g); + Assert::AreEqual(static_cast(0), b); + } + + TEST_METHOD(CheckValidARGB_ValidMixed_ReturnsTrue) + { + uint8_t a, r, g, b; + bool result = checkValidARGB(L"#12345678", &a, &r, &g, &b); + Assert::IsTrue(result); + Assert::AreEqual(static_cast(0x12), a); + Assert::AreEqual(static_cast(0x34), r); + Assert::AreEqual(static_cast(0x56), g); + Assert::AreEqual(static_cast(0x78), b); + } + + TEST_METHOD(CheckValidARGB_MissingHash_ReturnsFalse) + { + uint8_t a, r, g, b; + bool result = checkValidARGB(L"FFFFFFFF", &a, &r, &g, &b); + Assert::IsFalse(result); + } + + TEST_METHOD(CheckValidARGB_TooShort_ReturnsFalse) + { + uint8_t a, r, g, b; + bool result = checkValidARGB(L"#FFFFFF", &a, &r, &g, &b); + Assert::IsFalse(result); + } + + TEST_METHOD(CheckValidARGB_TooLong_ReturnsFalse) + { + uint8_t a, r, g, b; + bool result = checkValidARGB(L"#FFFFFFFFFF", &a, &r, &g, &b); + Assert::IsFalse(result); + } + + TEST_METHOD(CheckValidARGB_InvalidChars_ReturnsFalse) + { + uint8_t a, r, g, b; + bool result = checkValidARGB(L"#GGHHIIJJ", &a, &r, &g, &b); + Assert::IsFalse(result); + } + + TEST_METHOD(CheckValidARGB_LowercaseInvalid_ReturnsFalse) + { + uint8_t a, r, g, b; + bool result = checkValidARGB(L"#ffffffff", &a, &r, &g, &b); + Assert::IsFalse(result); + } + + TEST_METHOD(CheckValidARGB_EmptyString_ReturnsFalse) + { + uint8_t a, r, g, b; + bool result = checkValidARGB(L"", &a, &r, &g, &b); + Assert::IsFalse(result); + } + }; +} diff --git a/src/common/UnitTests-CommonUtils/ComObjectFactory.Tests.cpp b/src/common/UnitTests-CommonUtils/ComObjectFactory.Tests.cpp new file mode 100644 index 0000000000..8f86e64d47 --- /dev/null +++ b/src/common/UnitTests-CommonUtils/ComObjectFactory.Tests.cpp @@ -0,0 +1,228 @@ +#include "pch.h" +#include "TestHelpers.h" +#include + +using namespace Microsoft::VisualStudio::CppUnitTestFramework; + +namespace UnitTestsCommonUtils +{ + // Test COM object for testing the factory + class TestComObject : public IUnknown + { + public: + TestComObject() : m_refCount(1) {} + + HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, void** ppvObject) override + { + if (riid == IID_IUnknown) + { + *ppvObject = static_cast(this); + AddRef(); + return S_OK; + } + *ppvObject = nullptr; + return E_NOINTERFACE; + } + + ULONG STDMETHODCALLTYPE AddRef() override + { + return InterlockedIncrement(&m_refCount); + } + + ULONG STDMETHODCALLTYPE Release() override + { + ULONG count = InterlockedDecrement(&m_refCount); + if (count == 0) + { + delete this; + } + return count; + } + + private: + LONG m_refCount; + }; + + TEST_CLASS(ComObjectFactoryTests) + { + public: + TEST_METHOD(ComObjectFactory_Construction_DoesNotCrash) + { + com_object_factory factory; + Assert::IsTrue(true); + } + + TEST_METHOD(ComObjectFactory_QueryInterface_IUnknown_Succeeds) + { + com_object_factory factory; + IUnknown* pUnknown = nullptr; + + HRESULT hr = factory.QueryInterface(IID_IUnknown, reinterpret_cast(&pUnknown)); + + Assert::AreEqual(S_OK, hr); + Assert::IsNotNull(pUnknown); + + if (pUnknown) + { + pUnknown->Release(); + } + } + + TEST_METHOD(ComObjectFactory_QueryInterface_IClassFactory_Succeeds) + { + com_object_factory factory; + IClassFactory* pFactory = nullptr; + + HRESULT hr = factory.QueryInterface(IID_IClassFactory, reinterpret_cast(&pFactory)); + + Assert::AreEqual(S_OK, hr); + Assert::IsNotNull(pFactory); + + if (pFactory) + { + pFactory->Release(); + } + } + + TEST_METHOD(ComObjectFactory_QueryInterface_InvalidInterface_Fails) + { + com_object_factory factory; + void* pInterface = nullptr; + + // Random GUID that we don't support + GUID randomGuid = { 0x12345678, 0x1234, 0x1234, { 0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC, 0xDE, 0xF0 } }; + HRESULT hr = factory.QueryInterface(randomGuid, &pInterface); + + Assert::AreEqual(E_NOINTERFACE, hr); + Assert::IsNull(pInterface); + } + + TEST_METHOD(ComObjectFactory_AddRef_IncreasesRefCount) + { + com_object_factory factory; + + ULONG count1 = factory.AddRef(); + ULONG count2 = factory.AddRef(); + + Assert::IsTrue(count2 > count1); + + // Clean up + factory.Release(); + factory.Release(); + } + + TEST_METHOD(ComObjectFactory_Release_DecreasesRefCount) + { + com_object_factory factory; + + factory.AddRef(); + factory.AddRef(); + ULONG count1 = factory.Release(); + ULONG count2 = factory.Release(); + + Assert::IsTrue(count2 < count1); + } + + TEST_METHOD(ComObjectFactory_CreateInstance_NoAggregation_Succeeds) + { + com_object_factory factory; + IUnknown* pObj = nullptr; + + HRESULT hr = factory.CreateInstance(nullptr, IID_IUnknown, reinterpret_cast(&pObj)); + + Assert::AreEqual(S_OK, hr); + Assert::IsNotNull(pObj); + + if (pObj) + { + pObj->Release(); + } + } + + TEST_METHOD(ComObjectFactory_CreateInstance_WithAggregation_Fails) + { + com_object_factory factory; + TestComObject outer; + IUnknown* pObj = nullptr; + + // Aggregation should fail for our simple test object + HRESULT hr = factory.CreateInstance(&outer, IID_IUnknown, reinterpret_cast(&pObj)); + + Assert::AreEqual(CLASS_E_NOAGGREGATION, hr); + Assert::IsNull(pObj); + } + + TEST_METHOD(ComObjectFactory_CreateInstance_NullOutput_Fails) + { + com_object_factory factory; + + HRESULT hr = factory.CreateInstance(nullptr, IID_IUnknown, nullptr); + + Assert::AreEqual(E_POINTER, hr); + } + + TEST_METHOD(ComObjectFactory_LockServer_Lock_Succeeds) + { + com_object_factory factory; + + HRESULT hr = factory.LockServer(TRUE); + Assert::AreEqual(S_OK, hr); + + // Unlock + factory.LockServer(FALSE); + } + + TEST_METHOD(ComObjectFactory_LockServer_Unlock_Succeeds) + { + com_object_factory factory; + + factory.LockServer(TRUE); + HRESULT hr = factory.LockServer(FALSE); + + Assert::AreEqual(S_OK, hr); + } + + TEST_METHOD(ComObjectFactory_LockServer_MultipleLocks_Work) + { + com_object_factory factory; + + factory.LockServer(TRUE); + factory.LockServer(TRUE); + factory.LockServer(TRUE); + + factory.LockServer(FALSE); + factory.LockServer(FALSE); + HRESULT hr = factory.LockServer(FALSE); + + Assert::AreEqual(S_OK, hr); + } + + // Thread safety tests + TEST_METHOD(ComObjectFactory_ConcurrentCreateInstance_Works) + { + com_object_factory factory; + std::vector threads; + std::atomic successCount{ 0 }; + + for (int i = 0; i < 10; ++i) + { + threads.emplace_back([&factory, &successCount]() { + IUnknown* pObj = nullptr; + HRESULT hr = factory.CreateInstance(nullptr, IID_IUnknown, reinterpret_cast(&pObj)); + if (SUCCEEDED(hr) && pObj) + { + successCount++; + pObj->Release(); + } + }); + } + + for (auto& t : threads) + { + t.join(); + } + + Assert::AreEqual(10, successCount.load()); + } + }; +} diff --git a/src/common/UnitTests-CommonUtils/Elevation.Tests.cpp b/src/common/UnitTests-CommonUtils/Elevation.Tests.cpp new file mode 100644 index 0000000000..b9254da618 --- /dev/null +++ b/src/common/UnitTests-CommonUtils/Elevation.Tests.cpp @@ -0,0 +1,146 @@ +#include "pch.h" +#include "TestHelpers.h" +#include + +using namespace Microsoft::VisualStudio::CppUnitTestFramework; + +namespace UnitTestsCommonUtils +{ + TEST_CLASS(ElevationTests) + { + public: + // is_process_elevated tests + TEST_METHOD(IsProcessElevated_ReturnsBoolean) + { + bool result = is_process_elevated(false); + Assert::IsTrue(result == true || result == false); + } + + TEST_METHOD(IsProcessElevated_CachedValue_ReturnsSameResult) + { + bool result1 = is_process_elevated(true); + bool result2 = is_process_elevated(true); + + // Cached value should be consistent + Assert::AreEqual(result1, result2); + } + + TEST_METHOD(IsProcessElevated_UncachedValue_ReturnsBoolean) + { + bool result = is_process_elevated(false); + Assert::IsTrue(result == true || result == false); + } + + TEST_METHOD(IsProcessElevated_CachedAndUncached_AreConsistent) + { + // Both should return the same value for the same process + bool cached = is_process_elevated(true); + bool uncached = is_process_elevated(false); + + Assert::AreEqual(cached, uncached); + } + + // check_user_is_admin tests + TEST_METHOD(CheckUserIsAdmin_ReturnsBoolean) + { + bool result = check_user_is_admin(); + Assert::IsTrue(result == true || result == false); + } + + TEST_METHOD(CheckUserIsAdmin_ConsistentResults) + { + bool result1 = check_user_is_admin(); + bool result2 = check_user_is_admin(); + bool result3 = check_user_is_admin(); + + Assert::AreEqual(result1, result2); + Assert::AreEqual(result2, result3); + } + + // Relationship between elevation and admin + TEST_METHOD(ElevationAndAdmin_Relationship) + { + bool elevated = is_process_elevated(false); + bool admin = check_user_is_admin(); + (void)admin; + + // If elevated, user should typically be admin + // But user can be admin without process being elevated + if (elevated) + { + // Elevated process usually means admin user + // (though there are edge cases) + } + // Just verify both functions return without crashing + Assert::IsTrue(true); + } + + // IsProcessOfWindowElevated tests + TEST_METHOD(IsProcessOfWindowElevated_DesktopWindow_ReturnsBoolean) + { + HWND desktop = GetDesktopWindow(); + if (desktop) + { + bool result = IsProcessOfWindowElevated(desktop); + Assert::IsTrue(result == true || result == false); + } + Assert::IsTrue(true); + } + + TEST_METHOD(IsProcessOfWindowElevated_InvalidHwnd_DoesNotCrash) + { + bool result = IsProcessOfWindowElevated(nullptr); + // Should handle null HWND gracefully + Assert::IsTrue(result == true || result == false); + } + + // ProcessInfo struct tests + TEST_METHOD(ProcessInfo_DefaultConstruction) + { + ProcessInfo info{}; + Assert::AreEqual(static_cast(0), info.processID); + } + + // Thread safety tests + TEST_METHOD(IsProcessElevated_ThreadSafe) + { + std::vector threads; + std::atomic successCount{ 0 }; + + for (int i = 0; i < 10; ++i) + { + threads.emplace_back([&successCount]() { + for (int j = 0; j < 10; ++j) + { + is_process_elevated(j % 2 == 0); + successCount++; + } + }); + } + + for (auto& t : threads) + { + t.join(); + } + + Assert::AreEqual(100, successCount.load()); + } + + // Performance of cached value + TEST_METHOD(IsProcessElevated_CachedPerformance) + { + auto start = std::chrono::high_resolution_clock::now(); + + for (int i = 0; i < 10000; ++i) + { + is_process_elevated(true); + } + + auto end = std::chrono::high_resolution_clock::now(); + auto duration = std::chrono::duration_cast(end - start); + + // Cached calls should be very fast + Assert::IsTrue(duration.count() < 1000); + } + }; +} diff --git a/src/common/UnitTests-CommonUtils/ExcludedApps.Tests.cpp b/src/common/UnitTests-CommonUtils/ExcludedApps.Tests.cpp new file mode 100644 index 0000000000..9549d00b0e --- /dev/null +++ b/src/common/UnitTests-CommonUtils/ExcludedApps.Tests.cpp @@ -0,0 +1,182 @@ +#include "pch.h" +#include "TestHelpers.h" +#include + +using namespace Microsoft::VisualStudio::CppUnitTestFramework; + +namespace UnitTestsCommonUtils +{ + TEST_CLASS(ExcludedAppsTests) + { + public: + // find_app_name_in_path tests + TEST_METHOD(FindAppNameInPath_ExactMatch_ReturnsTrue) + { + std::wstring path = L"C:\\Program Files\\App\\notepad.exe"; + std::vector apps = { L"notepad.exe" }; + Assert::IsTrue(find_app_name_in_path(path, apps)); + } + + TEST_METHOD(FindAppNameInPath_NoMatch_ReturnsFalse) + { + std::wstring path = L"C:\\Program Files\\App\\notepad.exe"; + std::vector apps = { L"calc.exe" }; + Assert::IsFalse(find_app_name_in_path(path, apps)); + } + + TEST_METHOD(FindAppNameInPath_MultipleApps_FindsMatch) + { + std::wstring path = L"C:\\Program Files\\App\\notepad.exe"; + std::vector apps = { L"calc.exe", L"notepad.exe", L"word.exe" }; + Assert::IsTrue(find_app_name_in_path(path, apps)); + } + + TEST_METHOD(FindAppNameInPath_EmptyPath_ReturnsFalse) + { + std::wstring path = L""; + std::vector apps = { L"notepad.exe" }; + Assert::IsFalse(find_app_name_in_path(path, apps)); + } + + TEST_METHOD(FindAppNameInPath_EmptyApps_ReturnsFalse) + { + std::wstring path = L"C:\\Program Files\\App\\notepad.exe"; + std::vector apps = {}; + Assert::IsFalse(find_app_name_in_path(path, apps)); + } + + TEST_METHOD(FindAppNameInPath_PartialMatchInFolder_ReturnsFalse) + { + // "notepad" appears in folder name but not as the exe name + std::wstring path = L"C:\\notepad\\other.exe"; + std::vector apps = { L"notepad.exe" }; + Assert::IsFalse(find_app_name_in_path(path, apps)); + } + + TEST_METHOD(FindAppNameInPath_CaseSensitive_ReturnsFalse) + { + std::wstring path = L"C:\\Program Files\\App\\NOTEPAD.EXE"; + std::vector apps = { L"notepad.exe" }; + // The function does rfind which is case-sensitive + Assert::IsFalse(find_app_name_in_path(path, apps)); + } + + TEST_METHOD(FindAppNameInPath_MatchWithDifferentExtension_ReturnsFalse) + { + std::wstring path = L"C:\\Program Files\\App\\notepad.com"; + std::vector apps = { L"notepad.exe" }; + Assert::IsFalse(find_app_name_in_path(path, apps)); + } + + TEST_METHOD(FindAppNameInPath_MatchAtEndOfPath_ReturnsTrue) + { + std::wstring path = L"C:\\Windows\\System32\\notepad.exe"; + std::vector apps = { L"notepad.exe" }; + Assert::IsTrue(find_app_name_in_path(path, apps)); + } + + TEST_METHOD(FindAppNameInPath_UNCPath_Works) + { + std::wstring path = L"\\\\server\\share\\folder\\app.exe"; + std::vector apps = { L"app.exe" }; + Assert::IsTrue(find_app_name_in_path(path, apps)); + } + + // find_folder_in_path tests + TEST_METHOD(FindFolderInPath_FolderExists_ReturnsTrue) + { + std::wstring path = L"C:\\Program Files\\MyApp\\app.exe"; + std::vector folders = { L"Program Files" }; + Assert::IsTrue(find_folder_in_path(path, folders)); + } + + TEST_METHOD(FindFolderInPath_FolderNotExists_ReturnsFalse) + { + std::wstring path = L"C:\\Windows\\System32\\app.exe"; + std::vector folders = { L"Program Files" }; + Assert::IsFalse(find_folder_in_path(path, folders)); + } + + TEST_METHOD(FindFolderInPath_MultipleFolders_FindsMatch) + { + std::wstring path = L"C:\\Windows\\System32\\app.exe"; + std::vector folders = { L"Program Files", L"System32", L"Users" }; + Assert::IsTrue(find_folder_in_path(path, folders)); + } + + TEST_METHOD(FindFolderInPath_EmptyPath_ReturnsFalse) + { + std::wstring path = L""; + std::vector folders = { L"Windows" }; + Assert::IsFalse(find_folder_in_path(path, folders)); + } + + TEST_METHOD(FindFolderInPath_EmptyFolders_ReturnsFalse) + { + std::wstring path = L"C:\\Windows\\app.exe"; + std::vector folders = {}; + Assert::IsFalse(find_folder_in_path(path, folders)); + } + + TEST_METHOD(FindFolderInPath_PartialMatch_ReturnsTrue) + { + // find_folder_in_path uses rfind which finds substrings + std::wstring path = L"C:\\Windows\\System32\\app.exe"; + std::vector folders = { L"System" }; + Assert::IsTrue(find_folder_in_path(path, folders)); + } + + TEST_METHOD(FindFolderInPath_NestedFolder_ReturnsTrue) + { + std::wstring path = L"C:\\Program Files\\Company\\Product\\bin\\app.exe"; + std::vector folders = { L"Product" }; + Assert::IsTrue(find_folder_in_path(path, folders)); + } + + TEST_METHOD(FindFolderInPath_RootDrive_ReturnsTrue) + { + std::wstring path = L"C:\\folder\\app.exe"; + std::vector folders = { L"C:\\" }; + Assert::IsTrue(find_folder_in_path(path, folders)); + } + + TEST_METHOD(FindFolderInPath_UNCPath_Works) + { + std::wstring path = L"\\\\server\\share\\folder\\app.exe"; + std::vector folders = { L"share" }; + Assert::IsTrue(find_folder_in_path(path, folders)); + } + + TEST_METHOD(FindFolderInPath_CaseSensitive_ReturnsFalse) + { + std::wstring path = L"C:\\WINDOWS\\app.exe"; + std::vector folders = { L"windows" }; + // rfind is case-sensitive + Assert::IsFalse(find_folder_in_path(path, folders)); + } + + // Edge case tests + TEST_METHOD(FindAppNameInPath_AppNameInMiddleOfPath_HandlesCorrectly) + { + // The app name appears both in folder and as filename + std::wstring path = L"C:\\notepad\\bin\\notepad.exe"; + std::vector apps = { L"notepad.exe" }; + Assert::IsTrue(find_app_name_in_path(path, apps)); + } + + TEST_METHOD(FindAppNameInPath_JustFilename_ReturnsFalse) + { + std::wstring path = L"notepad.exe"; + std::vector apps = { L"notepad.exe" }; + // find_app_name_in_path expects a path separator to validate the executable segment + Assert::IsFalse(find_app_name_in_path(path, apps)); + } + + TEST_METHOD(FindFolderInPath_JustFilename_ReturnsFalse) + { + std::wstring path = L"app.exe"; + std::vector folders = { L"Windows" }; + Assert::IsFalse(find_folder_in_path(path, folders)); + } + }; +} diff --git a/src/common/UnitTests-CommonUtils/Exec.Tests.cpp b/src/common/UnitTests-CommonUtils/Exec.Tests.cpp new file mode 100644 index 0000000000..602e6efc2c --- /dev/null +++ b/src/common/UnitTests-CommonUtils/Exec.Tests.cpp @@ -0,0 +1,148 @@ +#include "pch.h" +#include "TestHelpers.h" +#include +#include + +using namespace Microsoft::VisualStudio::CppUnitTestFramework; + +namespace UnitTestsCommonUtils +{ + TEST_CLASS(ExecTests) + { + public: + TEST_METHOD(ExecAndReadOutput_EchoCommand_ReturnsOutput) + { + auto result = exec_and_read_output(L"cmd /c echo hello", 5000); + + Assert::IsTrue(result.has_value()); + Assert::IsFalse(result->empty()); + // Output should contain "hello" + Assert::IsTrue(result->find("hello") != std::string::npos); + } + + TEST_METHOD(ExecAndReadOutput_WhereCommand_ReturnsPath) + { + auto result = exec_and_read_output(L"where cmd", 5000); + + Assert::IsTrue(result.has_value()); + Assert::IsFalse(result->empty()); + // Should contain path to cmd.exe + Assert::IsTrue(result->find("cmd") != std::string::npos); + } + + TEST_METHOD(ExecAndReadOutput_DirCommand_ReturnsListing) + { + auto result = exec_and_read_output(L"cmd /c dir /b C:\\Windows", 5000); + + Assert::IsTrue(result.has_value()); + Assert::IsFalse(result->empty()); + // Should contain some common Windows folder names + std::string output = *result; + std::transform(output.begin(), output.end(), output.begin(), [](unsigned char ch) { return static_cast(std::tolower(ch)); }); + Assert::IsTrue(output.find("system32") != std::string::npos || + output.find("system") != std::string::npos); + } + + TEST_METHOD(ExecAndReadOutput_InvalidCommand_ReturnsEmptyOrError) + { + auto result = exec_and_read_output(L"nonexistentcommand12345", 5000); + + // Invalid command should either return nullopt or an error message + Assert::IsTrue(!result.has_value() || result->empty() || + result->find("not recognized") != std::string::npos || + result->find("error") != std::string::npos); + } + + TEST_METHOD(ExecAndReadOutput_EmptyCommand_DoesNotCrash) + { + auto result = exec_and_read_output(L"", 5000); + // Should handle empty command gracefully + Assert::IsTrue(true); + } + + TEST_METHOD(ExecAndReadOutput_TimeoutExpires_ReturnsAvailableOutput) + { + // Use a command that produces output slowly + // ping localhost will run for a while + auto start = std::chrono::steady_clock::now(); + + // Very short timeout + auto result = exec_and_read_output(L"ping localhost -n 10", 100); + + auto elapsed = std::chrono::duration_cast( + std::chrono::steady_clock::now() - start); + + // Should return within reasonable time + Assert::IsTrue(elapsed.count() < 5000); + } + + TEST_METHOD(ExecAndReadOutput_MultilineOutput_PreservesLines) + { + auto result = exec_and_read_output(L"cmd /c \"echo line1 & echo line2 & echo line3\"", 5000); + + Assert::IsTrue(result.has_value()); + // Should contain multiple lines + Assert::IsTrue(result->find("line1") != std::string::npos); + Assert::IsTrue(result->find("line2") != std::string::npos); + Assert::IsTrue(result->find("line3") != std::string::npos); + } + + TEST_METHOD(ExecAndReadOutput_UnicodeOutput_Works) + { + // Echo a simple ASCII string (Unicode test depends on system codepage) + auto result = exec_and_read_output(L"cmd /c echo test123", 5000); + + Assert::IsTrue(result.has_value()); + Assert::IsTrue(result->find("test123") != std::string::npos); + } + + TEST_METHOD(ExecAndReadOutput_LongTimeout_Works) + { + auto result = exec_and_read_output(L"cmd /c echo test", 60000); + + Assert::IsTrue(result.has_value()); + Assert::IsTrue(result->find("test") != std::string::npos); + } + + TEST_METHOD(ExecAndReadOutput_QuotedArguments_Work) + { + auto result = exec_and_read_output(L"cmd /c echo \"hello world\"", 5000); + + Assert::IsTrue(result.has_value()); + Assert::IsTrue(result->find("hello") != std::string::npos); + } + + TEST_METHOD(ExecAndReadOutput_EnvironmentVariable_Expanded) + { + auto result = exec_and_read_output(L"cmd /c echo %USERNAME%", 5000); + + Assert::IsTrue(result.has_value()); + // Should not contain the literal %USERNAME% but the actual username + // Or if not expanded, still should not crash + Assert::IsFalse(result->empty()); + } + + TEST_METHOD(ExecAndReadOutput_ExitCode_CommandFails) + { + // Command that exits with error + auto result = exec_and_read_output(L"cmd /c exit 1", 5000); + + // Should still return something (possibly empty) + // Just verify it doesn't crash + Assert::IsTrue(true); + } + + TEST_METHOD(ExecAndReadOutput_ZeroTimeout_DoesNotHang) + { + auto start = std::chrono::steady_clock::now(); + + auto result = exec_and_read_output(L"cmd /c echo test", 0); + + auto elapsed = std::chrono::duration_cast( + std::chrono::steady_clock::now() - start); + + // Should complete quickly with zero timeout + Assert::IsTrue(elapsed.count() < 5000); + } + }; +} diff --git a/src/common/UnitTests-CommonUtils/GameMode.Tests.cpp b/src/common/UnitTests-CommonUtils/GameMode.Tests.cpp new file mode 100644 index 0000000000..a75ad536c2 --- /dev/null +++ b/src/common/UnitTests-CommonUtils/GameMode.Tests.cpp @@ -0,0 +1,68 @@ +#include "pch.h" +#include "TestHelpers.h" +#include + +using namespace Microsoft::VisualStudio::CppUnitTestFramework; + +namespace UnitTestsCommonUtils +{ + TEST_CLASS(GameModeTests) + { + public: + TEST_METHOD(DetectGameMode_ReturnsBoolean) + { + // This function queries Windows game mode status + bool result = detect_game_mode(); + + // Result depends on current system state, but should be a valid boolean + Assert::IsTrue(result == true || result == false); + } + + TEST_METHOD(DetectGameMode_ConsistentResults) + { + // Multiple calls should return consistent results (unless game mode changes) + bool result1 = detect_game_mode(); + bool result2 = detect_game_mode(); + bool result3 = detect_game_mode(); + + // Results should be consistent across rapid calls + Assert::AreEqual(result1, result2); + Assert::AreEqual(result2, result3); + } + + TEST_METHOD(DetectGameMode_DoesNotCrash) + { + // Call multiple times to ensure no crash or memory leak + for (int i = 0; i < 100; ++i) + { + detect_game_mode(); + } + Assert::IsTrue(true); + } + + TEST_METHOD(DetectGameMode_ThreadSafe) + { + // Test that calling from multiple threads is safe + std::vector threads; + std::atomic successCount{ 0 }; + + for (int i = 0; i < 10; ++i) + { + threads.emplace_back([&successCount]() { + for (int j = 0; j < 10; ++j) + { + detect_game_mode(); + successCount++; + } + }); + } + + for (auto& t : threads) + { + t.join(); + } + + Assert::AreEqual(100, successCount.load()); + } + }; +} diff --git a/src/common/UnitTests-CommonUtils/Gpo.Tests.cpp b/src/common/UnitTests-CommonUtils/Gpo.Tests.cpp new file mode 100644 index 0000000000..74ebe3e82f --- /dev/null +++ b/src/common/UnitTests-CommonUtils/Gpo.Tests.cpp @@ -0,0 +1,218 @@ +#include "pch.h" +#include "TestHelpers.h" +#include + +using namespace Microsoft::VisualStudio::CppUnitTestFramework; +using namespace powertoys_gpo; + +namespace UnitTestsCommonUtils +{ + TEST_CLASS(GpoTests) + { + public: + // Helper to check if result is a valid gpo_rule_configured_t value + static constexpr bool IsValidGpoResult(gpo_rule_configured_t result) + { + return result == gpo_rule_configured_wrong_value || + result == gpo_rule_configured_unavailable || + result == gpo_rule_configured_not_configured || + result == gpo_rule_configured_disabled || + result == gpo_rule_configured_enabled; + } + + // gpo_rule_configured_t enum tests + TEST_METHOD(GpoRuleConfigured_EnumValues_AreDistinct) + { + Assert::AreNotEqual(static_cast(gpo_rule_configured_not_configured), + static_cast(gpo_rule_configured_enabled)); + Assert::AreNotEqual(static_cast(gpo_rule_configured_enabled), + static_cast(gpo_rule_configured_disabled)); + Assert::AreNotEqual(static_cast(gpo_rule_configured_not_configured), + static_cast(gpo_rule_configured_disabled)); + } + + // getConfiguredValue tests + TEST_METHOD(GetConfiguredValue_NonExistentKey_ReturnsNotConfigured) + { + auto result = getConfiguredValue(L"NonExistentPolicyValue12345"); + Assert::IsTrue(result == gpo_rule_configured_not_configured || + result == gpo_rule_configured_unavailable); + } + + // Utility enabled getters - these all follow the same pattern + TEST_METHOD(GetAllowExperimentationValue_ReturnsValidState) + { + auto result = getAllowExperimentationValue(); + Assert::IsTrue(IsValidGpoResult(result)); + } + + TEST_METHOD(GetConfiguredAlwaysOnTopEnabledValue_ReturnsValidState) + { + auto result = getConfiguredAlwaysOnTopEnabledValue(); + Assert::IsTrue(IsValidGpoResult(result)); + } + + TEST_METHOD(GetConfiguredAwakeEnabledValue_ReturnsValidState) + { + auto result = getConfiguredAwakeEnabledValue(); + Assert::IsTrue(IsValidGpoResult(result)); + } + + TEST_METHOD(GetConfiguredColorPickerEnabledValue_ReturnsValidState) + { + auto result = getConfiguredColorPickerEnabledValue(); + Assert::IsTrue(IsValidGpoResult(result)); + } + + TEST_METHOD(GetConfiguredFancyZonesEnabledValue_ReturnsValidState) + { + auto result = getConfiguredFancyZonesEnabledValue(); + Assert::IsTrue(IsValidGpoResult(result)); + } + + TEST_METHOD(GetConfiguredFileLocksmithEnabledValue_ReturnsValidState) + { + auto result = getConfiguredFileLocksmithEnabledValue(); + Assert::IsTrue(IsValidGpoResult(result)); + } + + TEST_METHOD(GetConfiguredImageResizerEnabledValue_ReturnsValidState) + { + auto result = getConfiguredImageResizerEnabledValue(); + Assert::IsTrue(IsValidGpoResult(result)); + } + + TEST_METHOD(GetConfiguredKeyboardManagerEnabledValue_ReturnsValidState) + { + auto result = getConfiguredKeyboardManagerEnabledValue(); + Assert::IsTrue(IsValidGpoResult(result)); + } + + TEST_METHOD(GetConfiguredPowerRenameEnabledValue_ReturnsValidState) + { + auto result = getConfiguredPowerRenameEnabledValue(); + Assert::IsTrue(IsValidGpoResult(result)); + } + + TEST_METHOD(GetConfiguredPowerLauncherEnabledValue_ReturnsValidState) + { + auto result = getConfiguredPowerLauncherEnabledValue(); + Assert::IsTrue(IsValidGpoResult(result)); + } + + TEST_METHOD(GetConfiguredShortcutGuideEnabledValue_ReturnsValidState) + { + auto result = getConfiguredShortcutGuideEnabledValue(); + Assert::IsTrue(IsValidGpoResult(result)); + } + + TEST_METHOD(GetConfiguredTextExtractorEnabledValue_ReturnsValidState) + { + auto result = getConfiguredTextExtractorEnabledValue(); + Assert::IsTrue(IsValidGpoResult(result)); + } + + TEST_METHOD(GetConfiguredHostsFileEditorEnabledValue_ReturnsValidState) + { + auto result = getConfiguredHostsFileEditorEnabledValue(); + Assert::IsTrue(IsValidGpoResult(result)); + } + + TEST_METHOD(GetConfiguredMousePointerCrosshairsEnabledValue_ReturnsValidState) + { + auto result = getConfiguredMousePointerCrosshairsEnabledValue(); + Assert::IsTrue(IsValidGpoResult(result)); + } + + TEST_METHOD(GetConfiguredMouseHighlighterEnabledValue_ReturnsValidState) + { + auto result = getConfiguredMouseHighlighterEnabledValue(); + Assert::IsTrue(IsValidGpoResult(result)); + } + + TEST_METHOD(GetConfiguredMouseJumpEnabledValue_ReturnsValidState) + { + auto result = getConfiguredMouseJumpEnabledValue(); + Assert::IsTrue(IsValidGpoResult(result)); + } + + TEST_METHOD(GetConfiguredFindMyMouseEnabledValue_ReturnsValidState) + { + auto result = getConfiguredFindMyMouseEnabledValue(); + Assert::IsTrue(IsValidGpoResult(result)); + } + + TEST_METHOD(GetConfiguredMouseWithoutBordersEnabledValue_ReturnsValidState) + { + auto result = getConfiguredMouseWithoutBordersEnabledValue(); + Assert::IsTrue(IsValidGpoResult(result)); + } + + TEST_METHOD(GetConfiguredAdvancedPasteEnabledValue_ReturnsValidState) + { + auto result = getConfiguredAdvancedPasteEnabledValue(); + Assert::IsTrue(IsValidGpoResult(result)); + } + + TEST_METHOD(GetConfiguredPeekEnabledValue_ReturnsValidState) + { + auto result = getConfiguredPeekEnabledValue(); + Assert::IsTrue(IsValidGpoResult(result)); + } + + TEST_METHOD(GetConfiguredRegistryPreviewEnabledValue_ReturnsValidState) + { + auto result = getConfiguredRegistryPreviewEnabledValue(); + Assert::IsTrue(IsValidGpoResult(result)); + } + + TEST_METHOD(GetConfiguredScreenRulerEnabledValue_ReturnsValidState) + { + auto result = getConfiguredScreenRulerEnabledValue(); + Assert::IsTrue(IsValidGpoResult(result)); + } + + TEST_METHOD(GetConfiguredCropAndLockEnabledValue_ReturnsValidState) + { + auto result = getConfiguredCropAndLockEnabledValue(); + Assert::IsTrue(IsValidGpoResult(result)); + } + + TEST_METHOD(GetConfiguredEnvironmentVariablesEnabledValue_ReturnsValidState) + { + auto result = getConfiguredEnvironmentVariablesEnabledValue(); + Assert::IsTrue(IsValidGpoResult(result)); + } + + // All GPO functions should not crash + TEST_METHOD(AllGpoFunctions_DoNotCrash) + { + getAllowExperimentationValue(); + getConfiguredAlwaysOnTopEnabledValue(); + getConfiguredAwakeEnabledValue(); + getConfiguredColorPickerEnabledValue(); + getConfiguredFancyZonesEnabledValue(); + getConfiguredFileLocksmithEnabledValue(); + getConfiguredImageResizerEnabledValue(); + getConfiguredKeyboardManagerEnabledValue(); + getConfiguredPowerRenameEnabledValue(); + getConfiguredPowerLauncherEnabledValue(); + getConfiguredShortcutGuideEnabledValue(); + getConfiguredTextExtractorEnabledValue(); + getConfiguredHostsFileEditorEnabledValue(); + getConfiguredMousePointerCrosshairsEnabledValue(); + getConfiguredMouseHighlighterEnabledValue(); + getConfiguredMouseJumpEnabledValue(); + getConfiguredFindMyMouseEnabledValue(); + getConfiguredMouseWithoutBordersEnabledValue(); + getConfiguredAdvancedPasteEnabledValue(); + getConfiguredPeekEnabledValue(); + getConfiguredRegistryPreviewEnabledValue(); + getConfiguredScreenRulerEnabledValue(); + getConfiguredCropAndLockEnabledValue(); + getConfiguredEnvironmentVariablesEnabledValue(); + + Assert::IsTrue(true); + } + }; +} diff --git a/src/common/UnitTests-CommonUtils/HDropIterator.Tests.cpp b/src/common/UnitTests-CommonUtils/HDropIterator.Tests.cpp new file mode 100644 index 0000000000..0679968964 --- /dev/null +++ b/src/common/UnitTests-CommonUtils/HDropIterator.Tests.cpp @@ -0,0 +1,200 @@ +#include "pch.h" +#include "TestHelpers.h" +#include +#include + +using namespace Microsoft::VisualStudio::CppUnitTestFramework; + +namespace UnitTestsCommonUtils +{ + TEST_CLASS(HDropIteratorTests) + { + public: + // Helper to create a test HDROP structure + static HGLOBAL CreateTestHDrop(const std::vector& files) + { + // Calculate required size + size_t size = sizeof(DROPFILES); + for (const auto& file : files) + { + size += (file.length() + 1) * sizeof(wchar_t); + } + size += sizeof(wchar_t); // Double null terminator + + HGLOBAL hGlobal = GlobalAlloc(GHND, size); + if (!hGlobal) return nullptr; + + DROPFILES* pDropFiles = static_cast(GlobalLock(hGlobal)); + if (!pDropFiles) + { + GlobalFree(hGlobal); + return nullptr; + } + + pDropFiles->pFiles = sizeof(DROPFILES); + pDropFiles->fWide = TRUE; + + wchar_t* pData = reinterpret_cast(reinterpret_cast(pDropFiles) + sizeof(DROPFILES)); + for (const auto& file : files) + { + wcscpy_s(pData, file.length() + 1, file.c_str()); + pData += file.length() + 1; + } + *pData = L'\0'; // Double null terminator + + GlobalUnlock(hGlobal); + return hGlobal; + } + + TEST_METHOD(HDropIterator_EmptyDrop_IsDoneImmediately) + { + HGLOBAL hGlobal = CreateTestHDrop({}); + if (!hGlobal) + { + Assert::IsTrue(true); // Skip if allocation failed + return; + } + + STGMEDIUM medium = {}; + medium.tymed = TYMED_HGLOBAL; + medium.hGlobal = hGlobal; + + // Without a proper IDataObject, we can't fully test + // Just verify the class can be instantiated conceptually + GlobalFree(hGlobal); + Assert::IsTrue(true); + } + + TEST_METHOD(HDropIterator_Iteration_Conceptual) + { + // This test verifies the concept of iteration + // Full integration testing requires a proper IDataObject + + std::vector testFiles = { + L"C:\\test\\file1.txt", + L"C:\\test\\file2.txt", + L"C:\\test\\file3.txt" + }; + + HGLOBAL hGlobal = CreateTestHDrop(testFiles); + if (!hGlobal) + { + Assert::IsTrue(true); + return; + } + + // Verify we can create the HDROP structure + DROPFILES* pDropFiles = static_cast(GlobalLock(hGlobal)); + Assert::IsNotNull(pDropFiles); + Assert::IsTrue(pDropFiles->fWide); + + GlobalUnlock(hGlobal); + GlobalFree(hGlobal); + Assert::IsTrue(true); + } + + TEST_METHOD(HDropIterator_SingleFile_Works) + { + std::vector testFiles = { L"C:\\test\\single.txt" }; + + HGLOBAL hGlobal = CreateTestHDrop(testFiles); + if (!hGlobal) + { + Assert::IsTrue(true); + return; + } + + // Verify structure + DROPFILES* pDropFiles = static_cast(GlobalLock(hGlobal)); + Assert::IsNotNull(pDropFiles); + + // Read back the file name + wchar_t* pData = reinterpret_cast(reinterpret_cast(pDropFiles) + pDropFiles->pFiles); + Assert::AreEqual(std::wstring(L"C:\\test\\single.txt"), std::wstring(pData)); + + GlobalUnlock(hGlobal); + GlobalFree(hGlobal); + } + + TEST_METHOD(HDropIterator_MultipleFiles_Structure) + { + std::vector testFiles = { + L"C:\\file1.txt", + L"C:\\file2.txt", + L"C:\\file3.txt" + }; + + HGLOBAL hGlobal = CreateTestHDrop(testFiles); + if (!hGlobal) + { + Assert::IsTrue(true); + return; + } + + DROPFILES* pDropFiles = static_cast(GlobalLock(hGlobal)); + Assert::IsNotNull(pDropFiles); + + // Count files by iterating through null-terminated strings + wchar_t* pData = reinterpret_cast(reinterpret_cast(pDropFiles) + pDropFiles->pFiles); + int count = 0; + while (*pData) + { + count++; + pData += wcslen(pData) + 1; + } + + Assert::AreEqual(3, count); + + GlobalUnlock(hGlobal); + GlobalFree(hGlobal); + } + + TEST_METHOD(HDropIterator_UnicodeFilenames_Work) + { + std::vector testFiles = { + L"C:\\test\\file.txt" + }; + + HGLOBAL hGlobal = CreateTestHDrop(testFiles); + if (!hGlobal) + { + Assert::IsTrue(true); + return; + } + + DROPFILES* pDropFiles = static_cast(GlobalLock(hGlobal)); + Assert::IsTrue(pDropFiles->fWide == TRUE); + + GlobalUnlock(hGlobal); + GlobalFree(hGlobal); + } + + TEST_METHOD(HDropIterator_LongFilenames_Work) + { + std::wstring longPath = L"C:\\"; + for (int i = 0; i < 20; ++i) + { + longPath += L"LongFolderName\\"; + } + longPath += L"file.txt"; + + std::vector testFiles = { longPath }; + + HGLOBAL hGlobal = CreateTestHDrop(testFiles); + if (!hGlobal) + { + Assert::IsTrue(true); + return; + } + + DROPFILES* pDropFiles = static_cast(GlobalLock(hGlobal)); + Assert::IsNotNull(pDropFiles); + + wchar_t* pData = reinterpret_cast(reinterpret_cast(pDropFiles) + pDropFiles->pFiles); + Assert::AreEqual(longPath, std::wstring(pData)); + + GlobalUnlock(hGlobal); + GlobalFree(hGlobal); + } + }; +} diff --git a/src/common/UnitTests-CommonUtils/HttpClient.Tests.cpp b/src/common/UnitTests-CommonUtils/HttpClient.Tests.cpp new file mode 100644 index 0000000000..34a3d1ba03 --- /dev/null +++ b/src/common/UnitTests-CommonUtils/HttpClient.Tests.cpp @@ -0,0 +1,152 @@ +#include "pch.h" +#include "TestHelpers.h" +#include + +using namespace Microsoft::VisualStudio::CppUnitTestFramework; + +namespace UnitTestsCommonUtils +{ + TEST_CLASS(HttpClientTests) + { + public: + // Note: Network tests may fail in offline environments + // These tests are designed to verify the API doesn't crash + + TEST_METHOD(HttpClient_DefaultConstruction) + { + http::HttpClient client; + // Should not crash + Assert::IsTrue(true); + } + + TEST_METHOD(HttpClient_Request_InvalidUri_ReturnsEmpty) + { + http::HttpClient client; + + try + { + // Invalid URI should not crash, may throw or return empty + auto result = client.request(winrt::Windows::Foundation::Uri(L"invalid://not-a-valid-uri")); + // If we get here, result may be empty or contain error + } + catch (...) + { + // Exception is acceptable for invalid URI + } + Assert::IsTrue(true); + } + + TEST_METHOD(HttpClient_Download_InvalidUri_DoesNotCrash) + { + http::HttpClient client; + TestHelpers::TempFile tempFile; + + try + { + auto result = client.download( + winrt::Windows::Foundation::Uri(L"https://invalid.invalid.invalid"), + tempFile.path()); + // May return false or throw + } + catch (...) + { + // Exception is acceptable for invalid/unreachable URI + } + Assert::IsTrue(true); + } + + TEST_METHOD(HttpClient_Download_WithCallback_DoesNotCrash) + { + http::HttpClient client; + TestHelpers::TempFile tempFile; + std::atomic callbackCount{ 0 }; + + try + { + auto result = client.download( + winrt::Windows::Foundation::Uri(L"https://invalid.invalid.invalid"), + tempFile.path(), + [&callbackCount]([[maybe_unused]] float progress) { + callbackCount++; + }); + } + catch (...) + { + // Exception is acceptable + } + Assert::IsTrue(true); + } + + TEST_METHOD(HttpClient_Download_EmptyPath_DoesNotCrash) + { + http::HttpClient client; + + try + { + auto result = client.download( + winrt::Windows::Foundation::Uri(L"https://example.com"), + L""); + } + catch (...) + { + // Exception is acceptable for empty path + } + Assert::IsTrue(true); + } + + // These tests require network access and may be skipped in offline environments + TEST_METHOD(HttpClient_Request_ValidUri_ReturnsResult) + { + // Skip this test in most CI environments + // Only run manually to verify network functionality + http::HttpClient client; + + try + { + // Use a reliable, fast-responding URL + auto result = client.request(winrt::Windows::Foundation::Uri(L"https://www.microsoft.com")); + // Result may or may not be successful depending on network + } + catch (...) + { + // Network errors are acceptable in test environment + } + Assert::IsTrue(true); + } + + // Thread safety test (doesn't require network) + TEST_METHOD(HttpClient_MultipleInstances_DoNotCrash) + { + std::vector> clients; + + for (int i = 0; i < 10; ++i) + { + clients.push_back(std::make_unique()); + } + + // All clients should coexist without crashing + Assert::AreEqual(static_cast(10), clients.size()); + } + + TEST_METHOD(HttpClient_ConcurrentConstruction_DoesNotCrash) + { + std::vector threads; + std::atomic successCount{ 0 }; + + for (int i = 0; i < 5; ++i) + { + threads.emplace_back([&successCount]() { + http::HttpClient client; + successCount++; + }); + } + + for (auto& t : threads) + { + t.join(); + } + + Assert::AreEqual(5, successCount.load()); + } + }; +} diff --git a/src/common/UnitTests-CommonUtils/Json.Tests.cpp b/src/common/UnitTests-CommonUtils/Json.Tests.cpp new file mode 100644 index 0000000000..8539ac29a3 --- /dev/null +++ b/src/common/UnitTests-CommonUtils/Json.Tests.cpp @@ -0,0 +1,283 @@ +#include "pch.h" +#include "TestHelpers.h" +#include + +using namespace Microsoft::VisualStudio::CppUnitTestFramework; +using namespace winrt::Windows::Data::Json; + +namespace UnitTestsCommonUtils +{ + TEST_CLASS(JsonTests) + { + public: + // from_file tests + TEST_METHOD(FromFile_NonExistentFile_ReturnsNullopt) + { + auto result = json::from_file(L"C:\\NonExistent\\File\\Path.json"); + Assert::IsFalse(result.has_value()); + } + + TEST_METHOD(FromFile_ValidJsonFile_ReturnsJsonObject) + { + TestHelpers::TempFile tempFile(L"", L".json"); + tempFile.write("{\"key\": \"value\"}"); + + auto result = json::from_file(tempFile.path()); + Assert::IsTrue(result.has_value()); + } + + TEST_METHOD(FromFile_InvalidJson_ReturnsNullopt) + { + TestHelpers::TempFile tempFile(L"", L".json"); + tempFile.write("not valid json {{{"); + + auto result = json::from_file(tempFile.path()); + Assert::IsFalse(result.has_value()); + } + + TEST_METHOD(FromFile_EmptyFile_ReturnsNullopt) + { + TestHelpers::TempFile tempFile(L"", L".json"); + // File is empty + + auto result = json::from_file(tempFile.path()); + Assert::IsFalse(result.has_value()); + } + + TEST_METHOD(FromFile_ValidComplexJson_ParsesCorrectly) + { + TestHelpers::TempFile tempFile(L"", L".json"); + tempFile.write("{\"name\": \"test\", \"value\": 42, \"enabled\": true}"); + + auto result = json::from_file(tempFile.path()); + Assert::IsTrue(result.has_value()); + + auto& obj = *result; + Assert::IsTrue(obj.HasKey(L"name")); + Assert::IsTrue(obj.HasKey(L"value")); + Assert::IsTrue(obj.HasKey(L"enabled")); + } + + // to_file tests + TEST_METHOD(ToFile_ValidObject_WritesFile) + { + TestHelpers::TempFile tempFile(L"", L".json"); + + JsonObject obj; + obj.SetNamedValue(L"key", JsonValue::CreateStringValue(L"value")); + json::to_file(tempFile.path(), obj); + + // Read back and verify + auto result = json::from_file(tempFile.path()); + Assert::IsTrue(result.has_value()); + Assert::IsTrue(result->HasKey(L"key")); + } + + TEST_METHOD(ToFile_ComplexObject_WritesFile) + { + TestHelpers::TempFile tempFile(L"", L".json"); + + JsonObject obj; + obj.SetNamedValue(L"name", JsonValue::CreateStringValue(L"test")); + obj.SetNamedValue(L"value", JsonValue::CreateNumberValue(42)); + obj.SetNamedValue(L"enabled", JsonValue::CreateBooleanValue(true)); + json::to_file(tempFile.path(), obj); + + auto result = json::from_file(tempFile.path()); + Assert::IsTrue(result.has_value()); + Assert::AreEqual(std::wstring(L"test"), std::wstring(result->GetNamedString(L"name"))); + Assert::AreEqual(42.0, result->GetNamedNumber(L"value")); + Assert::IsTrue(result->GetNamedBoolean(L"enabled")); + } + + // has tests + TEST_METHOD(Has_ExistingKey_ReturnsTrue) + { + JsonObject obj; + obj.SetNamedValue(L"key", JsonValue::CreateStringValue(L"value")); + Assert::IsTrue(json::has(obj, L"key", JsonValueType::String)); + } + + TEST_METHOD(Has_NonExistingKey_ReturnsFalse) + { + JsonObject obj; + Assert::IsFalse(json::has(obj, L"key", JsonValueType::String)); + } + + TEST_METHOD(Has_WrongType_ReturnsFalse) + { + JsonObject obj; + obj.SetNamedValue(L"key", JsonValue::CreateStringValue(L"value")); + Assert::IsFalse(json::has(obj, L"key", JsonValueType::Number)); + } + + TEST_METHOD(Has_NumberType_ReturnsTrue) + { + JsonObject obj; + obj.SetNamedValue(L"key", JsonValue::CreateNumberValue(42)); + Assert::IsTrue(json::has(obj, L"key", JsonValueType::Number)); + } + + TEST_METHOD(Has_BooleanType_ReturnsTrue) + { + JsonObject obj; + obj.SetNamedValue(L"key", JsonValue::CreateBooleanValue(true)); + Assert::IsTrue(json::has(obj, L"key", JsonValueType::Boolean)); + } + + TEST_METHOD(Has_ObjectType_ReturnsTrue) + { + JsonObject obj; + JsonObject nested; + obj.SetNamedValue(L"key", nested); + Assert::IsTrue(json::has(obj, L"key", JsonValueType::Object)); + } + + // value function tests + TEST_METHOD(Value_IntegerType_CreatesNumberValue) + { + auto val = json::value(42); + Assert::IsTrue(val.ValueType() == JsonValueType::Number); + Assert::AreEqual(42.0, val.GetNumber()); + } + + TEST_METHOD(Value_DoubleType_CreatesNumberValue) + { + auto val = json::value(3.14); + Assert::IsTrue(val.ValueType() == JsonValueType::Number); + Assert::AreEqual(3.14, val.GetNumber()); + } + + TEST_METHOD(Value_BooleanTrue_CreatesBooleanValue) + { + auto val = json::value(true); + Assert::IsTrue(val.ValueType() == JsonValueType::Boolean); + Assert::IsTrue(val.GetBoolean()); + } + + TEST_METHOD(Value_BooleanFalse_CreatesBooleanValue) + { + auto val = json::value(false); + Assert::IsTrue(val.ValueType() == JsonValueType::Boolean); + Assert::IsFalse(val.GetBoolean()); + } + + TEST_METHOD(Value_String_CreatesStringValue) + { + auto val = json::value(L"hello"); + Assert::IsTrue(val.ValueType() == JsonValueType::String); + Assert::AreEqual(std::wstring(L"hello"), std::wstring(val.GetString())); + } + + TEST_METHOD(Value_JsonObject_ReturnsJsonValue) + { + JsonObject obj; + obj.SetNamedValue(L"key", JsonValue::CreateStringValue(L"value")); + auto val = json::value(obj); + Assert::IsTrue(val.ValueType() == JsonValueType::Object); + } + + TEST_METHOD(Value_JsonValue_ReturnsIdentity) + { + auto original = JsonValue::CreateStringValue(L"test"); + auto result = json::value(original); + Assert::AreEqual(std::wstring(L"test"), std::wstring(result.GetString())); + } + + // get function tests + TEST_METHOD(Get_BooleanValue_ReturnsValue) + { + JsonObject obj; + obj.SetNamedValue(L"enabled", JsonValue::CreateBooleanValue(true)); + + bool result = false; + json::get(obj, L"enabled", result); + Assert::IsTrue(result); + } + + TEST_METHOD(Get_IntValue_ReturnsValue) + { + JsonObject obj; + obj.SetNamedValue(L"count", JsonValue::CreateNumberValue(42)); + + int result = 0; + json::get(obj, L"count", result); + Assert::AreEqual(42, result); + } + + TEST_METHOD(Get_DoubleValue_ReturnsValue) + { + JsonObject obj; + obj.SetNamedValue(L"ratio", JsonValue::CreateNumberValue(3.14)); + + double result = 0.0; + json::get(obj, L"ratio", result); + Assert::AreEqual(3.14, result); + } + + TEST_METHOD(Get_StringValue_ReturnsValue) + { + JsonObject obj; + obj.SetNamedValue(L"name", JsonValue::CreateStringValue(L"test")); + + std::wstring result; + json::get(obj, L"name", result); + Assert::AreEqual(std::wstring(L"test"), result); + } + + TEST_METHOD(Get_MissingKey_UsesDefault) + { + JsonObject obj; + + int result = 0; + json::get(obj, L"missing", result, 99); + Assert::AreEqual(99, result); + } + + TEST_METHOD(Get_MissingKeyNoDefault_PreservesOriginal) + { + JsonObject obj; + + int result = 42; + json::get(obj, L"missing", result); + // When key is missing and no default, original value is preserved + Assert::AreEqual(42, result); + } + + TEST_METHOD(Get_JsonObject_ReturnsObject) + { + JsonObject obj; + JsonObject nested; + nested.SetNamedValue(L"inner", JsonValue::CreateStringValue(L"value")); + obj.SetNamedValue(L"nested", nested); + + JsonObject result; + json::get(obj, L"nested", result); + Assert::IsTrue(result.HasKey(L"inner")); + } + + // Roundtrip tests + TEST_METHOD(Roundtrip_ComplexObject_PreservesData) + { + TestHelpers::TempFile tempFile(L"", L".json"); + + JsonObject original; + original.SetNamedValue(L"string", JsonValue::CreateStringValue(L"hello")); + original.SetNamedValue(L"number", JsonValue::CreateNumberValue(42)); + original.SetNamedValue(L"boolean", JsonValue::CreateBooleanValue(true)); + + JsonObject nested; + nested.SetNamedValue(L"inner", JsonValue::CreateStringValue(L"world")); + original.SetNamedValue(L"object", nested); + + json::to_file(tempFile.path(), original); + auto loaded = json::from_file(tempFile.path()); + + Assert::IsTrue(loaded.has_value()); + Assert::AreEqual(std::wstring(L"hello"), std::wstring(loaded->GetNamedString(L"string"))); + Assert::AreEqual(42.0, loaded->GetNamedNumber(L"number")); + Assert::IsTrue(loaded->GetNamedBoolean(L"boolean")); + Assert::AreEqual(std::wstring(L"world"), std::wstring(loaded->GetNamedObject(L"object").GetNamedString(L"inner"))); + } + }; +} diff --git a/src/common/UnitTests-CommonUtils/LoggerHelper.Tests.cpp b/src/common/UnitTests-CommonUtils/LoggerHelper.Tests.cpp new file mode 100644 index 0000000000..14967d8860 --- /dev/null +++ b/src/common/UnitTests-CommonUtils/LoggerHelper.Tests.cpp @@ -0,0 +1,180 @@ +#include "pch.h" +#include "TestHelpers.h" +#include + +using namespace Microsoft::VisualStudio::CppUnitTestFramework; +using namespace LoggerHelpers; + +namespace UnitTestsCommonUtils +{ + TEST_CLASS(LoggerHelperTests) + { + public: + // get_log_folder_path tests + TEST_METHOD(GetLogFolderPath_ValidAppPath_ReturnsPath) + { + auto result = get_log_folder_path(L"TestApp"); + + Assert::IsFalse(result.empty()); + // Should contain the app name or be a valid path + auto pathStr = result.wstring(); + Assert::IsTrue(pathStr.length() > 0); + } + + TEST_METHOD(GetLogFolderPath_EmptyAppPath_ReturnsPath) + { + auto result = get_log_folder_path(L""); + + // Should still return a base path + Assert::IsTrue(true); // Just verify no crash + } + + TEST_METHOD(GetLogFolderPath_SpecialCharacters_Works) + { + auto result = get_log_folder_path(L"Test App With Spaces"); + + // Should handle spaces in path + Assert::IsTrue(true); + } + + TEST_METHOD(GetLogFolderPath_ConsistentResults) + { + auto result1 = get_log_folder_path(L"TestApp"); + auto result2 = get_log_folder_path(L"TestApp"); + + Assert::AreEqual(result1.wstring(), result2.wstring()); + } + + // dir_exists tests + TEST_METHOD(DirExists_WindowsDirectory_ReturnsTrue) + { + bool result = dir_exists(std::filesystem::path(L"C:\\Windows")); + Assert::IsTrue(result); + } + + TEST_METHOD(DirExists_NonExistentDirectory_ReturnsFalse) + { + bool result = dir_exists(std::filesystem::path(L"C:\\NonExistentDir12345")); + Assert::IsFalse(result); + } + + TEST_METHOD(DirExists_FileInsteadOfDir_ReturnsTrue) + { + // notepad.exe is a file, not a directory + bool result = dir_exists(std::filesystem::path(L"C:\\Windows\\notepad.exe")); + Assert::IsTrue(result); + } + + TEST_METHOD(DirExists_EmptyPath_ReturnsFalse) + { + bool result = dir_exists(std::filesystem::path(L"")); + Assert::IsFalse(result); + } + + TEST_METHOD(DirExists_TempDirectory_ReturnsTrue) + { + wchar_t tempPath[MAX_PATH]; + GetTempPathW(MAX_PATH, tempPath); + + bool result = dir_exists(std::filesystem::path(tempPath)); + Assert::IsTrue(result); + } + + // delete_old_log_folder tests + TEST_METHOD(DeleteOldLogFolder_NonExistentFolder_DoesNotCrash) + { + delete_old_log_folder(std::filesystem::path(L"C:\\NonExistentLogFolder12345")); + Assert::IsTrue(true); + } + + TEST_METHOD(DeleteOldLogFolder_ValidEmptyFolder_Works) + { + TestHelpers::TempDirectory tempDir; + + // Create a subfolder structure + auto logFolder = std::filesystem::path(tempDir.path()) / L"logs"; + std::filesystem::create_directories(logFolder); + + Assert::IsTrue(std::filesystem::exists(logFolder)); + + delete_old_log_folder(logFolder); + + // Folder may or may not be deleted depending on implementation + Assert::IsTrue(true); + } + + // delete_other_versions_log_folders tests + TEST_METHOD(DeleteOtherVersionsLogFolders_NonExistentPath_DoesNotCrash) + { + delete_other_versions_log_folders(L"C:\\NonExistent\\Path", L"1.0.0"); + Assert::IsTrue(true); + } + + TEST_METHOD(DeleteOtherVersionsLogFolders_EmptyVersion_DoesNotCrash) + { + wchar_t tempPath[MAX_PATH]; + GetTempPathW(MAX_PATH, tempPath); + + delete_other_versions_log_folders(tempPath, L""); + Assert::IsTrue(true); + } + + // Thread safety tests + TEST_METHOD(GetLogFolderPath_ThreadSafe) + { + std::vector threads; + std::atomic successCount{ 0 }; + + for (int i = 0; i < 10; ++i) + { + threads.emplace_back([&successCount, i]() { + auto path = get_log_folder_path(L"TestApp" + std::to_wstring(i)); + if (!path.empty()) + { + successCount++; + } + }); + } + + for (auto& t : threads) + { + t.join(); + } + + Assert::AreEqual(10, successCount.load()); + } + + TEST_METHOD(DirExists_ThreadSafe) + { + std::vector threads; + std::atomic successCount{ 0 }; + + for (int i = 0; i < 10; ++i) + { + threads.emplace_back([&successCount]() { + for (int j = 0; j < 10; ++j) + { + dir_exists(std::filesystem::path(L"C:\\Windows")); + successCount++; + } + }); + } + + for (auto& t : threads) + { + t.join(); + } + + Assert::AreEqual(100, successCount.load()); + } + + // Path construction tests + TEST_METHOD(GetLogFolderPath_ReturnsValidFilesystemPath) + { + auto result = get_log_folder_path(L"TestApp"); + + // Should be a valid path that we can use with filesystem operations + Assert::IsTrue(result.is_absolute() || result.has_root_name() || !result.empty()); + } + }; +} diff --git a/src/common/UnitTests-CommonUtils/ModulesRegistry.Tests.cpp b/src/common/UnitTests-CommonUtils/ModulesRegistry.Tests.cpp new file mode 100644 index 0000000000..787a5c62a5 --- /dev/null +++ b/src/common/UnitTests-CommonUtils/ModulesRegistry.Tests.cpp @@ -0,0 +1,173 @@ +#include "pch.h" +#include "TestHelpers.h" +#include + +using namespace Microsoft::VisualStudio::CppUnitTestFramework; + +namespace UnitTestsCommonUtils +{ + static std::wstring GetInstallDir() + { + wchar_t path[MAX_PATH]; + GetModuleFileNameW(nullptr, path, MAX_PATH); + return std::filesystem::path{ path }.parent_path().wstring(); + } + + TEST_CLASS(ModulesRegistryTests) + { + public: + // Test that all changeset generator functions return valid changesets + TEST_METHOD(GetSvgPreviewHandlerChangeSet_ReturnsChangeSet) + { + auto changeSet = getSvgPreviewHandlerChangeSet(GetInstallDir(), false); + + Assert::IsFalse(changeSet.changes.empty()); + } + + TEST_METHOD(GetSvgThumbnailProviderChangeSet_ReturnsChangeSet) + { + auto changeSet = getSvgThumbnailHandlerChangeSet(GetInstallDir(), false); + + Assert::IsFalse(changeSet.changes.empty()); + } + + TEST_METHOD(GetMarkdownPreviewHandlerChangeSet_ReturnsChangeSet) + { + auto changeSet = getMdPreviewHandlerChangeSet(GetInstallDir(), false); + + Assert::IsFalse(changeSet.changes.empty()); + } + + TEST_METHOD(GetMonacoPreviewHandlerChangeSet_ReturnsChangeSet) + { + auto changeSet = getMonacoPreviewHandlerChangeSet(GetInstallDir(), false); + + Assert::IsFalse(changeSet.changes.empty()); + } + + TEST_METHOD(GetPdfPreviewHandlerChangeSet_ReturnsChangeSet) + { + auto changeSet = getPdfPreviewHandlerChangeSet(GetInstallDir(), false); + + Assert::IsFalse(changeSet.changes.empty()); + } + + TEST_METHOD(GetPdfThumbnailProviderChangeSet_ReturnsChangeSet) + { + auto changeSet = getPdfThumbnailHandlerChangeSet(GetInstallDir(), false); + + Assert::IsFalse(changeSet.changes.empty()); + } + + TEST_METHOD(GetGcodePreviewHandlerChangeSet_ReturnsChangeSet) + { + auto changeSet = getGcodePreviewHandlerChangeSet(GetInstallDir(), false); + + Assert::IsFalse(changeSet.changes.empty()); + } + + TEST_METHOD(GetGcodeThumbnailProviderChangeSet_ReturnsChangeSet) + { + auto changeSet = getGcodeThumbnailHandlerChangeSet(GetInstallDir(), false); + + Assert::IsFalse(changeSet.changes.empty()); + } + + TEST_METHOD(GetStlThumbnailProviderChangeSet_ReturnsChangeSet) + { + auto changeSet = getStlThumbnailHandlerChangeSet(GetInstallDir(), false); + + Assert::IsFalse(changeSet.changes.empty()); + } + + TEST_METHOD(GetQoiPreviewHandlerChangeSet_ReturnsChangeSet) + { + auto changeSet = getQoiPreviewHandlerChangeSet(GetInstallDir(), false); + + Assert::IsFalse(changeSet.changes.empty()); + } + + TEST_METHOD(GetQoiThumbnailProviderChangeSet_ReturnsChangeSet) + { + auto changeSet = getQoiThumbnailHandlerChangeSet(GetInstallDir(), false); + + Assert::IsFalse(changeSet.changes.empty()); + } + + // Test enabled vs disabled state + TEST_METHOD(ChangeSet_EnabledVsDisabled_MayDiffer) + { + auto enabledSet = getSvgPreviewHandlerChangeSet(GetInstallDir(), true); + auto disabledSet = getSvgPreviewHandlerChangeSet(GetInstallDir(), false); + + // Both should be valid change sets + Assert::IsFalse(enabledSet.changes.empty()); + Assert::IsFalse(disabledSet.changes.empty()); + } + + // Test getAllOnByDefaultModulesChangeSets + TEST_METHOD(GetAllOnByDefaultModulesChangeSets_ReturnsMultipleChangeSets) + { + auto changeSets = getAllOnByDefaultModulesChangeSets(GetInstallDir()); + + // Should return multiple changesets for all default-enabled modules + Assert::IsTrue(changeSets.size() > 0); + } + + // Test getAllModulesChangeSets + TEST_METHOD(GetAllModulesChangeSets_ReturnsChangeSets) + { + auto changeSets = getAllModulesChangeSets(GetInstallDir()); + + // Should return changesets for all modules + Assert::IsTrue(changeSets.size() > 0); + } + + TEST_METHOD(GetAllModulesChangeSets_ContainsMoreThanOnByDefault) + { + auto allSets = getAllModulesChangeSets(GetInstallDir()); + auto defaultSets = getAllOnByDefaultModulesChangeSets(GetInstallDir()); + + // All modules should be >= on-by-default modules + Assert::IsTrue(allSets.size() >= defaultSets.size()); + } + + // Test that changesets have valid structure + TEST_METHOD(ChangeSet_HasValidKeyPath) + { + auto changeSet = getSvgPreviewHandlerChangeSet(GetInstallDir(), false); + + Assert::IsFalse(changeSet.changes.empty()); + } + + // Test all changeset functions don't crash + TEST_METHOD(AllChangeSetFunctions_DoNotCrash) + { + auto installDir = GetInstallDir(); + getSvgPreviewHandlerChangeSet(installDir, true); + getSvgPreviewHandlerChangeSet(installDir, false); + getSvgThumbnailHandlerChangeSet(installDir, true); + getSvgThumbnailHandlerChangeSet(installDir, false); + getMdPreviewHandlerChangeSet(installDir, true); + getMdPreviewHandlerChangeSet(installDir, false); + getMonacoPreviewHandlerChangeSet(installDir, true); + getMonacoPreviewHandlerChangeSet(installDir, false); + getPdfPreviewHandlerChangeSet(installDir, true); + getPdfPreviewHandlerChangeSet(installDir, false); + getPdfThumbnailHandlerChangeSet(installDir, true); + getPdfThumbnailHandlerChangeSet(installDir, false); + getGcodePreviewHandlerChangeSet(installDir, true); + getGcodePreviewHandlerChangeSet(installDir, false); + getGcodeThumbnailHandlerChangeSet(installDir, true); + getGcodeThumbnailHandlerChangeSet(installDir, false); + getStlThumbnailHandlerChangeSet(installDir, true); + getStlThumbnailHandlerChangeSet(installDir, false); + getQoiPreviewHandlerChangeSet(installDir, true); + getQoiPreviewHandlerChangeSet(installDir, false); + getQoiThumbnailHandlerChangeSet(installDir, true); + getQoiThumbnailHandlerChangeSet(installDir, false); + + Assert::IsTrue(true); + } + }; +} diff --git a/src/common/UnitTests-CommonUtils/MsWindowsSettings.Tests.cpp b/src/common/UnitTests-CommonUtils/MsWindowsSettings.Tests.cpp new file mode 100644 index 0000000000..79f13c22a4 --- /dev/null +++ b/src/common/UnitTests-CommonUtils/MsWindowsSettings.Tests.cpp @@ -0,0 +1,65 @@ +#include "pch.h" +#include "TestHelpers.h" +#include + +using namespace Microsoft::VisualStudio::CppUnitTestFramework; + +namespace UnitTestsCommonUtils +{ + TEST_CLASS(MsWindowsSettingsTests) + { + public: + TEST_METHOD(GetAnimationsEnabled_ReturnsBoolean) + { + bool result = GetAnimationsEnabled(); + + // Should return a valid boolean + Assert::IsTrue(result == true || result == false); + } + + TEST_METHOD(GetAnimationsEnabled_ConsistentResults) + { + // Multiple calls should return consistent results + bool result1 = GetAnimationsEnabled(); + bool result2 = GetAnimationsEnabled(); + bool result3 = GetAnimationsEnabled(); + + Assert::AreEqual(result1, result2); + Assert::AreEqual(result2, result3); + } + + TEST_METHOD(GetAnimationsEnabled_DoesNotCrash) + { + // Call multiple times to ensure stability + for (int i = 0; i < 100; ++i) + { + GetAnimationsEnabled(); + } + Assert::IsTrue(true); + } + + TEST_METHOD(GetAnimationsEnabled_ThreadSafe) + { + std::vector threads; + std::atomic successCount{ 0 }; + + for (int i = 0; i < 10; ++i) + { + threads.emplace_back([&successCount]() { + for (int j = 0; j < 10; ++j) + { + GetAnimationsEnabled(); + successCount++; + } + }); + } + + for (auto& t : threads) + { + t.join(); + } + + Assert::AreEqual(100, successCount.load()); + } + }; +} diff --git a/src/common/UnitTests-CommonUtils/MsiUtils.Tests.cpp b/src/common/UnitTests-CommonUtils/MsiUtils.Tests.cpp new file mode 100644 index 0000000000..b0515b9f93 --- /dev/null +++ b/src/common/UnitTests-CommonUtils/MsiUtils.Tests.cpp @@ -0,0 +1,146 @@ +#include "pch.h" +#include "TestHelpers.h" +#include + +using namespace Microsoft::VisualStudio::CppUnitTestFramework; + +namespace UnitTestsCommonUtils +{ + TEST_CLASS(MsiUtilsTests) + { + public: + // GetMsiPackageInstalledPath tests + TEST_METHOD(GetMsiPackageInstalledPath_PerUser_DoesNotCrash) + { + auto result = GetMsiPackageInstalledPath(true); + // Result depends on installation state, but should not crash + Assert::IsTrue(true); + } + + TEST_METHOD(GetMsiPackageInstalledPath_PerMachine_DoesNotCrash) + { + auto result = GetMsiPackageInstalledPath(false); + // Result depends on installation state, but should not crash + Assert::IsTrue(true); + } + + TEST_METHOD(GetMsiPackageInstalledPath_ConsistentResults) + { + auto result1 = GetMsiPackageInstalledPath(true); + auto result2 = GetMsiPackageInstalledPath(true); + + // Results should be consistent + Assert::AreEqual(result1.has_value(), result2.has_value()); + if (result1.has_value() && result2.has_value()) + { + Assert::AreEqual(*result1, *result2); + } + } + + TEST_METHOD(GetMsiPackageInstalledPath_PerUserVsPerMachine_MayDiffer) + { + auto perUser = GetMsiPackageInstalledPath(true); + auto perMachine = GetMsiPackageInstalledPath(false); + + // These may or may not be equal depending on installation + // Just verify they don't crash + Assert::IsTrue(true); + } + + // GetMsiPackagePath tests + TEST_METHOD(GetMsiPackagePath_DoesNotCrash) + { + auto result = GetMsiPackagePath(); + // Result depends on installation state, but should not crash + Assert::IsTrue(true); + } + + TEST_METHOD(GetMsiPackagePath_ConsistentResults) + { + auto result1 = GetMsiPackagePath(); + auto result2 = GetMsiPackagePath(); + + // Results should be consistent + Assert::AreEqual(result1, result2); + } + + // Thread safety tests + TEST_METHOD(GetMsiPackageInstalledPath_ThreadSafe) + { + std::vector threads; + std::atomic successCount{ 0 }; + + for (int i = 0; i < 5; ++i) + { + threads.emplace_back([&successCount]() { + for (int j = 0; j < 5; ++j) + { + GetMsiPackageInstalledPath(j % 2 == 0); + successCount++; + } + }); + } + + for (auto& t : threads) + { + t.join(); + } + + Assert::AreEqual(25, successCount.load()); + } + + TEST_METHOD(GetMsiPackagePath_ThreadSafe) + { + std::vector threads; + std::atomic successCount{ 0 }; + + for (int i = 0; i < 5; ++i) + { + threads.emplace_back([&successCount]() { + for (int j = 0; j < 5; ++j) + { + GetMsiPackagePath(); + successCount++; + } + }); + } + + for (auto& t : threads) + { + t.join(); + } + + Assert::AreEqual(25, successCount.load()); + } + + // Return value format tests + TEST_METHOD(GetMsiPackageInstalledPath_ReturnsValidPathOrEmpty) + { + auto path = GetMsiPackageInstalledPath(true); + + if (path.has_value() && !path->empty()) + { + // If a path is returned, it should contain backslash or be a valid path format + Assert::IsTrue(path->find(L'\\') != std::wstring::npos || + path->find(L'/') != std::wstring::npos || + path->length() >= 2); // At minimum drive letter + colon + } + // No value or empty is also valid (not installed) + Assert::IsTrue(true); + } + + TEST_METHOD(GetMsiPackagePath_ReturnsValidPathOrEmpty) + { + auto path = GetMsiPackagePath(); + + if (!path.empty()) + { + // If a path is returned, it should be a valid path format + Assert::IsTrue(path.find(L'\\') != std::wstring::npos || + path.find(L'/') != std::wstring::npos || + path.length() >= 2); + } + Assert::IsTrue(true); + } + }; +} diff --git a/src/common/UnitTests-CommonUtils/OsDetect.Tests.cpp b/src/common/UnitTests-CommonUtils/OsDetect.Tests.cpp new file mode 100644 index 0000000000..7b6642246e --- /dev/null +++ b/src/common/UnitTests-CommonUtils/OsDetect.Tests.cpp @@ -0,0 +1,107 @@ +#include "pch.h" +#include "TestHelpers.h" +#include + +using namespace Microsoft::VisualStudio::CppUnitTestFramework; + +namespace UnitTestsCommonUtils +{ + TEST_CLASS(OsDetectTests) + { + public: + // IsAPIContractVxAvailable tests + TEST_METHOD(IsAPIContractV8Available_ReturnsBoolean) + { + // This test verifies the function runs without crashing + // The actual result depends on the OS version + bool result = IsAPIContractV8Available(); + // Result is either true or false, both are valid + Assert::IsTrue(result == true || result == false); + } + + TEST_METHOD(IsAPIContractVxAvailable_V1_ReturnsTrue) + { + // API contract v1 should be available on any modern Windows + bool result = IsAPIContractVxAvailable<1>(); + Assert::IsTrue(result); + } + + TEST_METHOD(IsAPIContractVxAvailable_V5_ReturnsBooleanConsistently) + { + // Call multiple times to verify caching works correctly + bool result1 = IsAPIContractVxAvailable<5>(); + bool result2 = IsAPIContractVxAvailable<5>(); + bool result3 = IsAPIContractVxAvailable<5>(); + Assert::AreEqual(result1, result2); + Assert::AreEqual(result2, result3); + } + + TEST_METHOD(IsAPIContractVxAvailable_V10_ReturnsBoolean) + { + bool result = IsAPIContractVxAvailable<10>(); + // Result depends on Windows version, but should not crash + Assert::IsTrue(result == true || result == false); + } + + TEST_METHOD(IsAPIContractVxAvailable_V15_ReturnsBoolean) + { + bool result = IsAPIContractVxAvailable<15>(); + // Higher API versions, may or may not be available + Assert::IsTrue(result == true || result == false); + } + + // Is19H1OrHigher tests + TEST_METHOD(Is19H1OrHigher_ReturnsBoolean) + { + bool result = Is19H1OrHigher(); + // Result depends on OS version, but should not crash + Assert::IsTrue(result == true || result == false); + } + + TEST_METHOD(Is19H1OrHigher_ReturnsSameAsV8Contract) + { + // Is19H1OrHigher is implemented as IsAPIContractV8Available + bool is19H1 = Is19H1OrHigher(); + bool isV8 = IsAPIContractV8Available(); + Assert::AreEqual(is19H1, isV8); + } + + TEST_METHOD(Is19H1OrHigher_ConsistentAcrossMultipleCalls) + { + bool result1 = Is19H1OrHigher(); + bool result2 = Is19H1OrHigher(); + bool result3 = Is19H1OrHigher(); + Assert::AreEqual(result1, result2); + Assert::AreEqual(result2, result3); + } + + // Static caching behavior tests + TEST_METHOD(StaticCaching_DifferentContractVersions_IndependentResults) + { + // Each template instantiation has its own static variable + bool v1 = IsAPIContractVxAvailable<1>(); + (void)v1; // Suppress unused variable warning + + // v1 should be true on any modern Windows + Assert::IsTrue(v1); + } + + // Performance test (optional - verifies caching) + TEST_METHOD(Performance_MultipleCallsAreFast) + { + // The static caching should make subsequent calls very fast + auto start = std::chrono::high_resolution_clock::now(); + + for (int i = 0; i < 10000; ++i) + { + Is19H1OrHigher(); + } + + auto end = std::chrono::high_resolution_clock::now(); + auto duration = std::chrono::duration_cast(end - start); + + // 10000 calls should complete in well under 1 second due to caching + Assert::IsTrue(duration.count() < 1000); + } + }; +} diff --git a/src/common/UnitTests-CommonUtils/Package.Tests.cpp b/src/common/UnitTests-CommonUtils/Package.Tests.cpp new file mode 100644 index 0000000000..be082d6fe7 --- /dev/null +++ b/src/common/UnitTests-CommonUtils/Package.Tests.cpp @@ -0,0 +1,180 @@ +#include "pch.h" +#include "TestHelpers.h" +#include + +using namespace Microsoft::VisualStudio::CppUnitTestFramework; +using namespace package; + +namespace UnitTestsCommonUtils +{ + TEST_CLASS(PackageTests) + { + public: + // IsWin11OrGreater tests + TEST_METHOD(IsWin11OrGreater_ReturnsBoolean) + { + bool result = IsWin11OrGreater(); + Assert::IsTrue(result == true || result == false); + } + + TEST_METHOD(IsWin11OrGreater_ConsistentResults) + { + bool result1 = IsWin11OrGreater(); + bool result2 = IsWin11OrGreater(); + bool result3 = IsWin11OrGreater(); + + Assert::AreEqual(result1, result2); + Assert::AreEqual(result2, result3); + } + + // PACKAGE_VERSION struct tests + TEST_METHOD(PackageVersion_DefaultConstruction) + { + PACKAGE_VERSION version{}; + Assert::AreEqual(static_cast(0), version.Major); + Assert::AreEqual(static_cast(0), version.Minor); + Assert::AreEqual(static_cast(0), version.Build); + Assert::AreEqual(static_cast(0), version.Revision); + } + + TEST_METHOD(PackageVersion_Assignment) + { + PACKAGE_VERSION version{}; + version.Major = 1; + version.Minor = 2; + version.Build = 3; + version.Revision = 4; + + Assert::AreEqual(static_cast(1), version.Major); + Assert::AreEqual(static_cast(2), version.Minor); + Assert::AreEqual(static_cast(3), version.Build); + Assert::AreEqual(static_cast(4), version.Revision); + } + + // ComInitializer tests + TEST_METHOD(ComInitializer_InitializesAndUninitializesCom) + { + { + ComInitializer comInit; + // COM should be initialized within this scope + } + // COM should be uninitialized after scope + + // Verify we can initialize again + { + ComInitializer comInit2; + } + + Assert::IsTrue(true); + } + + TEST_METHOD(ComInitializer_MultipleInstances) + { + ComInitializer init1; + ComInitializer init2; + ComInitializer init3; + + // Multiple initializations should work (COM uses reference counting) + Assert::IsTrue(true); + } + + // GetRegisteredPackage tests + TEST_METHOD(GetRegisteredPackage_NonExistentPackage_ReturnsEmpty) + { + auto result = GetRegisteredPackage(L"NonExistentPackage12345", false); + + // Should return empty for non-existent package + Assert::IsFalse(result.has_value()); + } + + TEST_METHOD(GetRegisteredPackage_EmptyName_DoesNotCrash) + { + auto result = GetRegisteredPackage(L"", false); + // Behavior may vary based on package enumeration; just ensure it doesn't crash. + Assert::IsTrue(true); + } + + // IsPackageRegisteredWithPowerToysVersion tests + TEST_METHOD(IsPackageRegisteredWithPowerToysVersion_NonExistentPackage_ReturnsFalse) + { + bool result = IsPackageRegisteredWithPowerToysVersion(L"NonExistentPackage12345"); + Assert::IsFalse(result); + } + + TEST_METHOD(IsPackageRegisteredWithPowerToysVersion_EmptyName_ReturnsFalse) + { + bool result = IsPackageRegisteredWithPowerToysVersion(L""); + Assert::IsFalse(result); + } + + // FindMsixFile tests + TEST_METHOD(FindMsixFile_NonExistentDirectory_ReturnsEmpty) + { + auto result = FindMsixFile(L"C:\\NonExistentDirectory12345", false); + Assert::IsTrue(result.empty()); + } + + TEST_METHOD(FindMsixFile_SystemDirectory_DoesNotCrash) + { + // System32 probably doesn't have MSIX files, but shouldn't crash + auto result = FindMsixFile(L"C:\\Windows\\System32", false); + // May or may not find files, but should not crash + Assert::IsTrue(true); + } + + TEST_METHOD(FindMsixFile_RecursiveSearch_DoesNotCrash) + { + // Use temp directory which should exist + wchar_t tempPath[MAX_PATH]; + GetTempPathW(MAX_PATH, tempPath); + + auto result = FindMsixFile(tempPath, true); + // May or may not find files, but should not crash + Assert::IsTrue(true); + } + + // GetPackageNameAndVersionFromAppx tests + TEST_METHOD(GetPackageNameAndVersionFromAppx_NonExistentFile_ReturnsFalse) + { + std::wstring name; + PACKAGE_VERSION version{}; + + bool result = GetPackageNameAndVersionFromAppx(L"C:\\NonExistent\\file.msix", name, version); + Assert::IsFalse(result); + } + + TEST_METHOD(GetPackageNameAndVersionFromAppx_EmptyPath_ReturnsFalse) + { + std::wstring name; + PACKAGE_VERSION version{}; + + bool result = GetPackageNameAndVersionFromAppx(L"", name, version); + Assert::IsFalse(result); + } + + // Thread safety + TEST_METHOD(IsWin11OrGreater_ThreadSafe) + { + std::vector threads; + std::atomic successCount{ 0 }; + + for (int i = 0; i < 10; ++i) + { + threads.emplace_back([&successCount]() { + for (int j = 0; j < 10; ++j) + { + IsWin11OrGreater(); + successCount++; + } + }); + } + + for (auto& t : threads) + { + t.join(); + } + + Assert::AreEqual(100, successCount.load()); + } + }; +} diff --git a/src/common/UnitTests-CommonUtils/ProcessApi.Tests.cpp b/src/common/UnitTests-CommonUtils/ProcessApi.Tests.cpp new file mode 100644 index 0000000000..912d3ca2f2 --- /dev/null +++ b/src/common/UnitTests-CommonUtils/ProcessApi.Tests.cpp @@ -0,0 +1,136 @@ +#include "pch.h" +#include "TestHelpers.h" +#include + +using namespace Microsoft::VisualStudio::CppUnitTestFramework; + +namespace UnitTestsCommonUtils +{ + TEST_CLASS(ProcessApiTests) + { + public: + TEST_METHOD(GetProcessHandlesByName_CurrentProcess_ReturnsHandles) + { + // Get current process executable name + wchar_t path[MAX_PATH]; + GetModuleFileNameW(nullptr, path, MAX_PATH); + + // Extract just the filename + std::wstring fullPath(path); + auto lastSlash = fullPath.rfind(L'\\'); + std::wstring exeName = (lastSlash != std::wstring::npos) ? + fullPath.substr(lastSlash + 1) : fullPath; + + auto handles = getProcessHandlesByName(exeName, PROCESS_QUERY_LIMITED_INFORMATION); + + // Should find at least our own process + Assert::IsFalse(handles.empty()); + + // Handles are RAII-managed + } + + TEST_METHOD(GetProcessHandlesByName_NonExistentProcess_ReturnsEmpty) + { + auto handles = getProcessHandlesByName(L"NonExistentProcess12345.exe", PROCESS_QUERY_LIMITED_INFORMATION); + Assert::IsTrue(handles.empty()); + } + + TEST_METHOD(GetProcessHandlesByName_EmptyName_ReturnsEmpty) + { + auto handles = getProcessHandlesByName(L"", PROCESS_QUERY_LIMITED_INFORMATION); + Assert::IsTrue(handles.empty()); + } + + TEST_METHOD(GetProcessHandlesByName_Explorer_ReturnsHandles) + { + // Explorer.exe should typically be running + auto handles = getProcessHandlesByName(L"explorer.exe", PROCESS_QUERY_LIMITED_INFORMATION); + + // Handles are RAII-managed + + // May or may not find explorer depending on system state + // Just verify it doesn't crash + Assert::IsTrue(true); + } + + TEST_METHOD(GetProcessHandlesByName_CaseInsensitive_Works) + { + // Get current process name in uppercase + wchar_t path[MAX_PATH]; + GetModuleFileNameW(nullptr, path, MAX_PATH); + + std::wstring fullPath(path); + auto lastSlash = fullPath.rfind(L'\\'); + std::wstring exeName = (lastSlash != std::wstring::npos) ? + fullPath.substr(lastSlash + 1) : fullPath; + + // Convert to uppercase + std::wstring upperName = exeName; + std::transform(upperName.begin(), upperName.end(), upperName.begin(), ::towupper); + + auto handles = getProcessHandlesByName(upperName, PROCESS_QUERY_LIMITED_INFORMATION); + + // Handles are RAII-managed + + // The function may or may not be case insensitive - just don't crash + Assert::IsTrue(true); + } + + TEST_METHOD(GetProcessHandlesByName_DifferentAccessRights_Works) + { + wchar_t path[MAX_PATH]; + GetModuleFileNameW(nullptr, path, MAX_PATH); + + std::wstring fullPath(path); + auto lastSlash = fullPath.rfind(L'\\'); + std::wstring exeName = (lastSlash != std::wstring::npos) ? + fullPath.substr(lastSlash + 1) : fullPath; + + // Try with different access rights + auto handles1 = getProcessHandlesByName(exeName, PROCESS_QUERY_INFORMATION); + auto handles2 = getProcessHandlesByName(exeName, PROCESS_VM_READ); + + // Handles are RAII-managed + + // Just verify no crashes + Assert::IsTrue(true); + } + + TEST_METHOD(GetProcessHandlesByName_SystemProcess_MayRequireElevation) + { + // System processes might require elevation + auto handles = getProcessHandlesByName(L"System", PROCESS_QUERY_LIMITED_INFORMATION); + + // Handles are RAII-managed + + // Just verify no crashes + Assert::IsTrue(true); + } + + TEST_METHOD(GetProcessHandlesByName_ValidHandles_AreUsable) + { + wchar_t path[MAX_PATH]; + GetModuleFileNameW(nullptr, path, MAX_PATH); + + std::wstring fullPath(path); + auto lastSlash = fullPath.rfind(L'\\'); + std::wstring exeName = (lastSlash != std::wstring::npos) ? + fullPath.substr(lastSlash + 1) : fullPath; + + auto handles = getProcessHandlesByName(exeName, PROCESS_QUERY_LIMITED_INFORMATION); + + bool foundValidHandle = false; + for (auto& handle : handles) + { + // Try to use the handle + DWORD exitCode; + if (GetExitCodeProcess(handle.get(), &exitCode)) + { + foundValidHandle = true; + } + } + + Assert::IsTrue(foundValidHandle || handles.empty()); + } + }; +} diff --git a/src/common/UnitTests-CommonUtils/ProcessPath.Tests.cpp b/src/common/UnitTests-CommonUtils/ProcessPath.Tests.cpp new file mode 100644 index 0000000000..888a512097 --- /dev/null +++ b/src/common/UnitTests-CommonUtils/ProcessPath.Tests.cpp @@ -0,0 +1,153 @@ +#include "pch.h" +#include "TestHelpers.h" +#include + +using namespace Microsoft::VisualStudio::CppUnitTestFramework; + +namespace UnitTestsCommonUtils +{ + TEST_CLASS(ProcessPathTests) + { + public: + // get_process_path (by PID) tests + TEST_METHOD(GetProcessPath_CurrentProcess_ReturnsPath) + { + DWORD pid = GetCurrentProcessId(); + auto path = get_process_path(pid); + + Assert::IsFalse(path.empty()); + Assert::IsTrue(path.find(L".exe") != std::wstring::npos || + path.find(L".dll") != std::wstring::npos); + } + + TEST_METHOD(GetProcessPath_InvalidPid_ReturnsEmpty) + { + DWORD invalidPid = 0xFFFFFFFF; + auto path = get_process_path(invalidPid); + + // Should return empty for invalid PID + Assert::IsTrue(path.empty()); + } + + TEST_METHOD(GetProcessPath_ZeroPid_ReturnsEmpty) + { + auto path = get_process_path(static_cast(0)); + // PID 0 is the System Idle Process, might return empty or a path + // Just verify it doesn't crash + Assert::IsTrue(true); + } + + TEST_METHOD(GetProcessPath_SystemPid_DoesNotCrash) + { + // PID 4 is typically the System process + auto path = get_process_path(static_cast(4)); + // May return empty due to access rights, but shouldn't crash + Assert::IsTrue(true); + } + + // get_module_filename tests + TEST_METHOD(GetModuleFilename_NullModule_ReturnsExePath) + { + auto path = get_module_filename(nullptr); + + Assert::IsFalse(path.empty()); + Assert::IsTrue(path.find(L".exe") != std::wstring::npos || + path.find(L".dll") != std::wstring::npos); + } + + TEST_METHOD(GetModuleFilename_Kernel32_ReturnsPath) + { + HMODULE kernel32 = GetModuleHandleW(L"kernel32.dll"); + Assert::IsNotNull(kernel32); + + auto path = get_module_filename(kernel32); + + Assert::IsFalse(path.empty()); + // Should contain kernel32 (case insensitive check) + std::wstring lowerPath = path; + std::transform(lowerPath.begin(), lowerPath.end(), lowerPath.begin(), ::towlower); + Assert::IsTrue(lowerPath.find(L"kernel32") != std::wstring::npos); + } + + TEST_METHOD(GetModuleFilename_InvalidModule_ReturnsEmpty) + { + auto path = get_module_filename(reinterpret_cast(0x12345678)); + // Invalid module should return empty + Assert::IsTrue(path.empty()); + } + + // get_module_folderpath tests + TEST_METHOD(GetModuleFolderpath_NullModule_ReturnsFolder) + { + auto folder = get_module_folderpath(nullptr, true); + + Assert::IsFalse(folder.empty()); + // Should not end with .exe when removeFilename is true + Assert::IsTrue(folder.find(L".exe") == std::wstring::npos); + // Should end with backslash or be a valid folder path + Assert::IsTrue(folder.back() == L'\\' || folder.find(L"\\") != std::wstring::npos); + } + + TEST_METHOD(GetModuleFolderpath_KeepFilename_ReturnsFullPath) + { + auto fullPath = get_module_folderpath(nullptr, false); + + Assert::IsFalse(fullPath.empty()); + // Should contain .exe or .dll when not removing filename + Assert::IsTrue(fullPath.find(L".exe") != std::wstring::npos || + fullPath.find(L".dll") != std::wstring::npos); + } + + TEST_METHOD(GetModuleFolderpath_Kernel32_ReturnsSystem32) + { + HMODULE kernel32 = GetModuleHandleW(L"kernel32.dll"); + Assert::IsNotNull(kernel32); + + auto folder = get_module_folderpath(kernel32, true); + + Assert::IsFalse(folder.empty()); + // Should be in system32 folder + std::wstring lowerPath = folder; + std::transform(lowerPath.begin(), lowerPath.end(), lowerPath.begin(), ::towlower); + Assert::IsTrue(lowerPath.find(L"system32") != std::wstring::npos || + lowerPath.find(L"syswow64") != std::wstring::npos); + } + + // get_process_path (by HWND) tests + TEST_METHOD(GetProcessPath_DesktopWindow_ReturnsPath) + { + HWND desktop = GetDesktopWindow(); + Assert::IsNotNull(desktop); + + auto path = get_process_path(desktop); + // Desktop window should return a path + // (could be explorer.exe or empty depending on system) + Assert::IsTrue(true); // Just verify it doesn't crash + } + + TEST_METHOD(GetProcessPath_InvalidHwnd_ReturnsEmpty) + { + auto path = get_process_path(reinterpret_cast(0x12345678)); + Assert::IsTrue(path.empty()); + } + + TEST_METHOD(GetProcessPath_NullHwnd_ReturnsEmpty) + { + auto path = get_process_path(static_cast(nullptr)); + Assert::IsTrue(path.empty()); + } + + // Consistency tests + TEST_METHOD(Consistency_ModuleFilenameAndFolderpath_AreRelated) + { + auto fullPath = get_module_filename(nullptr); + auto folder = get_module_folderpath(nullptr, true); + + Assert::IsFalse(fullPath.empty()); + Assert::IsFalse(folder.empty()); + + // Full path should start with the folder + Assert::IsTrue(fullPath.find(folder) == 0 || folder.find(fullPath.substr(0, folder.length())) == 0); + } + }; +} diff --git a/src/common/UnitTests-CommonUtils/ProcessWaiter.Tests.cpp b/src/common/UnitTests-CommonUtils/ProcessWaiter.Tests.cpp new file mode 100644 index 0000000000..e16b763dd8 --- /dev/null +++ b/src/common/UnitTests-CommonUtils/ProcessWaiter.Tests.cpp @@ -0,0 +1,127 @@ +#include "pch.h" +#include "TestHelpers.h" +#include + +using namespace Microsoft::VisualStudio::CppUnitTestFramework; +using namespace ProcessWaiter; + +namespace UnitTestsCommonUtils +{ + TEST_CLASS(ProcessWaiterTests) + { + public: + TEST_METHOD(OnProcessTerminate_InvalidPid_DoesNotCrash) + { + std::atomic called{ false }; + + // Use a very unlikely PID (negative value as string will fail conversion) + OnProcessTerminate(L"invalid", [&called](DWORD) { + called = true; + }); + + // Wait briefly + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + + // Should not crash, callback may or may not be called depending on implementation + Assert::IsTrue(true); + } + + TEST_METHOD(OnProcessTerminate_NonExistentPid_DoesNotCrash) + { + std::atomic called{ false }; + + // Use a PID that likely doesn't exist + OnProcessTerminate(L"999999999", [&called](DWORD) { + called = true; + }); + + // Wait briefly + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + + // Should not crash + Assert::IsTrue(true); + } + + TEST_METHOD(OnProcessTerminate_ZeroPid_DoesNotCrash) + { + std::atomic called{ false }; + + OnProcessTerminate(L"0", [&called](DWORD) { + called = true; + }); + + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + Assert::IsTrue(true); + } + + TEST_METHOD(OnProcessTerminate_CurrentProcessPid_DoesNotTerminate) + { + std::atomic called{ false }; + + // Use current process PID - it shouldn't terminate during test + std::wstring pid = std::to_wstring(GetCurrentProcessId()); + + OnProcessTerminate(pid, [&called](DWORD) { + called = true; + }); + + // Wait briefly - current process should not terminate + std::this_thread::sleep_for(std::chrono::milliseconds(200)); + + // Callback should not have been called since process is still running + Assert::IsFalse(called); + } + + TEST_METHOD(OnProcessTerminate_EmptyCallback_DoesNotCrash) + { + // Test with an empty function + OnProcessTerminate(L"999999999", std::function()); + + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + Assert::IsTrue(true); + } + + TEST_METHOD(OnProcessTerminate_MultipleCallsForSamePid_DoesNotCrash) + { + std::atomic counter{ 0 }; + std::wstring pid = std::to_wstring(GetCurrentProcessId()); + + // Multiple waits on same (running) process + for (int i = 0; i < 5; ++i) + { + OnProcessTerminate(pid, [&counter](DWORD) { + counter++; + }); + } + + std::this_thread::sleep_for(std::chrono::milliseconds(200)); + + // None should have been called since process is running + Assert::AreEqual(0, counter.load()); + } + + TEST_METHOD(OnProcessTerminate_NegativeNumberString_DoesNotCrash) + { + std::atomic called{ false }; + + OnProcessTerminate(L"-1", [&called](DWORD) { + called = true; + }); + + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + Assert::IsTrue(true); + } + + TEST_METHOD(OnProcessTerminate_LargeNumber_DoesNotCrash) + { + std::atomic called{ false }; + + OnProcessTerminate(L"18446744073709551615", [&called](DWORD) { + called = true; + }); + + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + Assert::IsTrue(true); + } + }; +} diff --git a/src/common/UnitTests-CommonUtils/Registry.Tests.cpp b/src/common/UnitTests-CommonUtils/Registry.Tests.cpp new file mode 100644 index 0000000000..be72750d6b --- /dev/null +++ b/src/common/UnitTests-CommonUtils/Registry.Tests.cpp @@ -0,0 +1,61 @@ +#include "pch.h" +#include "TestHelpers.h" +#include + +using namespace Microsoft::VisualStudio::CppUnitTestFramework; + +namespace UnitTestsCommonUtils +{ + TEST_CLASS(RegistryTests) + { + public: + // Note: These tests use HKCU which doesn't require elevation + + TEST_METHOD(InstallScope_Registry_CanReadAndWrite) + { + TestHelpers::TestRegistryKey testKey(L"RegistryTest"); + Assert::IsTrue(testKey.isValid()); + + // Write a test value + Assert::IsTrue(testKey.setStringValue(L"TestValue", L"TestData")); + Assert::IsTrue(testKey.setDwordValue(L"TestDword", 42)); + } + + TEST_METHOD(Registry_ValueChange_StringValue) + { + registry::ValueChange change{ HKEY_CURRENT_USER, L"Software\\PowerToys\\Test", L"TestValue", std::wstring{ L"TestData" } }; + + Assert::AreEqual(std::wstring(L"Software\\PowerToys\\Test"), change.path); + Assert::IsTrue(change.name.has_value()); + Assert::AreEqual(std::wstring(L"TestValue"), *change.name); + Assert::AreEqual(std::wstring(L"TestData"), std::get(change.value)); + } + + TEST_METHOD(Registry_ValueChange_DwordValue) + { + registry::ValueChange change{ HKEY_CURRENT_USER, L"Software\\PowerToys\\Test", L"TestDword", static_cast(42) }; + + Assert::AreEqual(std::wstring(L"Software\\PowerToys\\Test"), change.path); + Assert::IsTrue(change.name.has_value()); + Assert::AreEqual(std::wstring(L"TestDword"), *change.name); + Assert::AreEqual(static_cast(42), std::get(change.value)); + } + + TEST_METHOD(Registry_ChangeSet_AddChanges) + { + registry::ChangeSet changeSet; + + changeSet.changes.push_back({ HKEY_CURRENT_USER, L"Software\\PowerToys\\Test", L"Value1", std::wstring{ L"Data1" } }); + changeSet.changes.push_back({ HKEY_CURRENT_USER, L"Software\\PowerToys\\Test", L"Value2", static_cast(123) }); + + Assert::AreEqual(static_cast(2), changeSet.changes.size()); + } + + TEST_METHOD(InstallScope_GetCurrentInstallScope_ReturnsValidValue) + { + auto scope = registry::install_scope::get_current_install_scope(); + Assert::IsTrue(scope == registry::install_scope::InstallScope::PerMachine || + scope == registry::install_scope::InstallScope::PerUser); + } + }; +} diff --git a/src/common/UnitTests-CommonUtils/Resources.Tests.cpp b/src/common/UnitTests-CommonUtils/Resources.Tests.cpp new file mode 100644 index 0000000000..2dda45b6f7 --- /dev/null +++ b/src/common/UnitTests-CommonUtils/Resources.Tests.cpp @@ -0,0 +1,144 @@ +#include "pch.h" +#include "TestHelpers.h" +#include + +using namespace Microsoft::VisualStudio::CppUnitTestFramework; + +namespace UnitTestsCommonUtils +{ + TEST_CLASS(ResourcesTests) + { + public: + // get_resource_string tests with current module + TEST_METHOD(GetResourceString_NonExistentId_ReturnsFallback) + { + HINSTANCE instance = GetModuleHandleW(nullptr); + + auto result = get_resource_string(99999, instance, L"fallback"); + Assert::AreEqual(std::wstring(L"fallback"), result); + } + + TEST_METHOD(GetResourceString_NullInstance_UsesFallback) + { + auto result = get_resource_string(99999, nullptr, L"fallback"); + // Should return fallback or empty string + Assert::IsTrue(result == L"fallback" || result.empty()); + } + + TEST_METHOD(GetResourceString_EmptyFallback_ReturnsEmpty) + { + HINSTANCE instance = GetModuleHandleW(nullptr); + + auto result = get_resource_string(99999, instance, L""); + Assert::IsTrue(result.empty()); + } + + // get_english_fallback_string tests + TEST_METHOD(GetEnglishFallbackString_NonExistentId_ReturnsEmpty) + { + HINSTANCE instance = GetModuleHandleW(nullptr); + + auto result = get_english_fallback_string(99999, instance); + // Should return empty or the resource if it exists + Assert::IsTrue(true); // Just verify no crash + } + + TEST_METHOD(GetEnglishFallbackString_NullInstance_DoesNotCrash) + { + auto result = get_english_fallback_string(99999, nullptr); + Assert::IsTrue(true); // Just verify no crash + } + + // get_resource_string_language_override tests + TEST_METHOD(GetResourceStringLanguageOverride_NonExistentId_ReturnsEmpty) + { + HINSTANCE instance = GetModuleHandleW(nullptr); + + auto result = get_resource_string_language_override(99999, instance); + // Should return empty for non-existent resource + Assert::IsTrue(result.empty() || !result.empty()); // Valid either way + } + + TEST_METHOD(GetResourceStringLanguageOverride_NullInstance_DoesNotCrash) + { + auto result = get_resource_string_language_override(99999, nullptr); + Assert::IsTrue(true); + } + + // Thread safety tests + TEST_METHOD(GetResourceString_ThreadSafe) + { + HINSTANCE instance = GetModuleHandleW(nullptr); + std::vector threads; + std::atomic successCount{ 0 }; + + for (int i = 0; i < 10; ++i) + { + threads.emplace_back([&successCount, instance]() { + for (int j = 0; j < 10; ++j) + { + get_resource_string(99999, instance, L"fallback"); + successCount++; + } + }); + } + + for (auto& t : threads) + { + t.join(); + } + + Assert::AreEqual(100, successCount.load()); + } + + // Kernel32 resource tests (has known resources) + TEST_METHOD(GetResourceString_Kernel32_DoesNotCrash) + { + HMODULE kernel32 = GetModuleHandleW(L"kernel32.dll"); + if (kernel32) + { + // Kernel32 has resources, but we don't know exact IDs + // Just verify it doesn't crash + get_resource_string(1, kernel32, L"fallback"); + get_resource_string(100, kernel32, L"fallback"); + get_resource_string(1000, kernel32, L"fallback"); + } + Assert::IsTrue(true); + } + + // Performance test + TEST_METHOD(GetResourceString_Performance_Acceptable) + { + HINSTANCE instance = GetModuleHandleW(nullptr); + + auto start = std::chrono::high_resolution_clock::now(); + + for (int i = 0; i < 1000; ++i) + { + get_resource_string(99999, instance, L"fallback"); + } + + auto end = std::chrono::high_resolution_clock::now(); + auto duration = std::chrono::duration_cast(end - start); + + // 1000 lookups should complete in under 1 second + Assert::IsTrue(duration.count() < 1000); + } + + // Edge case tests + TEST_METHOD(GetResourceString_ZeroId_DoesNotCrash) + { + HINSTANCE instance = GetModuleHandleW(nullptr); + auto result = get_resource_string(0, instance, L"fallback"); + Assert::IsTrue(true); + } + + TEST_METHOD(GetResourceString_MaxUintId_DoesNotCrash) + { + HINSTANCE instance = GetModuleHandleW(nullptr); + auto result = get_resource_string(UINT_MAX, instance, L"fallback"); + Assert::IsTrue(true); + } + + }; +} diff --git a/src/common/UnitTests-CommonUtils/Serialized.Tests.cpp b/src/common/UnitTests-CommonUtils/Serialized.Tests.cpp new file mode 100644 index 0000000000..7d4121ca3b --- /dev/null +++ b/src/common/UnitTests-CommonUtils/Serialized.Tests.cpp @@ -0,0 +1,286 @@ +#include "pch.h" +#include "TestHelpers.h" +#include + +using namespace Microsoft::VisualStudio::CppUnitTestFramework; + +namespace UnitTestsCommonUtils +{ + TEST_CLASS(SerializedTests) + { + public: + // Basic Read tests + TEST_METHOD(Read_DefaultState_ReturnsDefaultValue) + { + Serialized s; + int value = -1; + s.Read([&value](const int& v) { + value = v; + }); + Assert::AreEqual(0, value); // Default constructed int is 0 + } + + TEST_METHOD(Read_StringType_ReturnsEmpty) + { + Serialized s; + std::string value = "initial"; + s.Read([&value](const std::string& v) { + value = v; + }); + Assert::AreEqual(std::string(""), value); + } + + // Basic Access tests + TEST_METHOD(Access_ModifyValue_ValueIsModified) + { + Serialized s; + s.Access([](int& v) { + v = 42; + }); + + int value = 0; + s.Read([&value](const int& v) { + value = v; + }); + Assert::AreEqual(42, value); + } + + TEST_METHOD(Access_ModifyString_StringIsModified) + { + Serialized s; + s.Access([](std::string& v) { + v = "hello"; + }); + + std::string value; + s.Read([&value](const std::string& v) { + value = v; + }); + Assert::AreEqual(std::string("hello"), value); + } + + TEST_METHOD(Access_MultipleModifications_LastValuePersists) + { + Serialized s; + s.Access([](int& v) { v = 1; }); + s.Access([](int& v) { v = 2; }); + s.Access([](int& v) { v = 3; }); + + int value = 0; + s.Read([&value](const int& v) { + value = v; + }); + Assert::AreEqual(3, value); + } + + // Reset tests + TEST_METHOD(Reset_AfterModification_ReturnsDefault) + { + Serialized s; + s.Access([](int& v) { v = 42; }); + s.Reset(); + + int value = -1; + s.Read([&value](const int& v) { + value = v; + }); + Assert::AreEqual(0, value); + } + + TEST_METHOD(Reset_String_ReturnsEmpty) + { + Serialized s; + s.Access([](std::string& v) { v = "hello"; }); + s.Reset(); + + std::string value = "initial"; + s.Read([&value](const std::string& v) { + value = v; + }); + Assert::AreEqual(std::string(""), value); + } + + // Complex type tests + TEST_METHOD(Serialized_VectorType_Works) + { + Serialized> s; + s.Access([](std::vector& v) { + v.push_back(1); + v.push_back(2); + v.push_back(3); + }); + + size_t size = 0; + int sum = 0; + s.Read([&size, &sum](const std::vector& v) { + size = v.size(); + for (int i : v) sum += i; + }); + + Assert::AreEqual(static_cast(3), size); + Assert::AreEqual(6, sum); + } + + TEST_METHOD(Serialized_MapType_Works) + { + Serialized> s; + s.Access([](std::map& v) { + v["one"] = 1; + v["two"] = 2; + }); + + int value = 0; + s.Read([&value](const std::map& v) { + auto it = v.find("two"); + if (it != v.end()) { + value = it->second; + } + }); + + Assert::AreEqual(2, value); + } + + // Thread safety tests + TEST_METHOD(ThreadSafety_ConcurrentReads_NoDataRace) + { + Serialized s; + s.Access([](int& v) { v = 42; }); + + std::atomic readCount{ 0 }; + std::vector threads; + + for (int i = 0; i < 10; ++i) + { + threads.emplace_back([&s, &readCount]() { + for (int j = 0; j < 100; ++j) + { + s.Read([&readCount](const int& v) { + if (v == 42) { + readCount++; + } + }); + } + }); + } + + for (auto& t : threads) + { + t.join(); + } + + Assert::AreEqual(1000, readCount.load()); + } + + TEST_METHOD(ThreadSafety_ConcurrentAccessAndRead_NoDataRace) + { + Serialized s; + std::atomic done{ false }; + std::atomic accessCount{ 0 }; + std::atomic readersReady{ 0 }; + std::atomic start{ false }; + + // Writer thread + std::thread writer([&s, &done, &accessCount, &readersReady, &start]() { + while (readersReady.load() < 5) + { + std::this_thread::yield(); + } + start = true; + for (int i = 0; i < 100; ++i) + { + s.Access([i](int& v) { + v = i; + }); + accessCount++; + } + done = true; + }); + + // Reader threads + std::vector readers; + std::atomic readAttempts{ 0 }; + + for (int i = 0; i < 5; ++i) + { + readers.emplace_back([&s, &done, &readAttempts, &readersReady, &start]() { + readersReady++; + while (!start) + { + std::this_thread::yield(); + } + while (!done) + { + s.Read([](const int& v) { + // Just read the value + (void)v; + }); + readAttempts++; + } + }); + } + + writer.join(); + for (auto& t : readers) + { + t.join(); + } + + // Verify all access calls completed + Assert::AreEqual(100, accessCount.load()); + // Verify reads happened + Assert::IsTrue(readAttempts > 0); + } + + // Struct type test + TEST_METHOD(Serialized_StructType_Works) + { + struct TestStruct + { + int x = 0; + std::string name; + }; + + Serialized s; + s.Access([](TestStruct& v) { + v.x = 10; + v.name = "test"; + }); + + int x = 0; + std::string name; + s.Read([&x, &name](const TestStruct& v) { + x = v.x; + name = v.name; + }); + + Assert::AreEqual(10, x); + Assert::AreEqual(std::string("test"), name); + } + + TEST_METHOD(Reset_StructType_ResetsToDefault) + { + struct TestStruct + { + int x = 0; + std::string name; + }; + + Serialized s; + s.Access([](TestStruct& v) { + v.x = 10; + v.name = "test"; + }); + s.Reset(); + + int x = -1; + std::string name = "not empty"; + s.Read([&x, &name](const TestStruct& v) { + x = v.x; + name = v.name; + }); + + Assert::AreEqual(0, x); + Assert::AreEqual(std::string(""), name); + } + }; +} diff --git a/src/common/UnitTests-CommonUtils/StringUtils.Tests.cpp b/src/common/UnitTests-CommonUtils/StringUtils.Tests.cpp new file mode 100644 index 0000000000..d669f61b10 --- /dev/null +++ b/src/common/UnitTests-CommonUtils/StringUtils.Tests.cpp @@ -0,0 +1,283 @@ +#include "pch.h" +#include "TestHelpers.h" +#include + +using namespace Microsoft::VisualStudio::CppUnitTestFramework; + +namespace UnitTestsCommonUtils +{ + TEST_CLASS(StringUtilsTests) + { + public: + // left_trim tests + TEST_METHOD(LeftTrim_EmptyString_ReturnsEmpty) + { + std::string_view input = ""; + auto result = left_trim(input); + Assert::AreEqual(std::string_view(""), result); + } + + TEST_METHOD(LeftTrim_NoWhitespace_ReturnsOriginal) + { + std::string_view input = "hello"; + auto result = left_trim(input); + Assert::AreEqual(std::string_view("hello"), result); + } + + TEST_METHOD(LeftTrim_LeadingSpaces_TrimsSpaces) + { + std::string_view input = " hello"; + auto result = left_trim(input); + Assert::AreEqual(std::string_view("hello"), result); + } + + TEST_METHOD(LeftTrim_LeadingTabs_TrimsTabs) + { + std::string_view input = "\t\thello"; + auto result = left_trim(input); + Assert::AreEqual(std::string_view("hello"), result); + } + + TEST_METHOD(LeftTrim_LeadingNewlines_TrimsNewlines) + { + std::string_view input = "\r\n\nhello"; + auto result = left_trim(input); + Assert::AreEqual(std::string_view("hello"), result); + } + + TEST_METHOD(LeftTrim_MixedWhitespace_TrimsAll) + { + std::string_view input = " \t\r\nhello"; + auto result = left_trim(input); + Assert::AreEqual(std::string_view("hello"), result); + } + + TEST_METHOD(LeftTrim_TrailingWhitespace_PreservesTrailing) + { + std::string_view input = " hello "; + auto result = left_trim(input); + Assert::AreEqual(std::string_view("hello "), result); + } + + TEST_METHOD(LeftTrim_OnlyWhitespace_ReturnsEmpty) + { + std::string_view input = " \t\r\n"; + auto result = left_trim(input); + Assert::AreEqual(std::string_view(""), result); + } + + TEST_METHOD(LeftTrim_CustomChars_TrimsSpecified) + { + std::string_view input = "xxxhello"; + auto result = left_trim(input, std::string_view("x")); + Assert::AreEqual(std::string_view("hello"), result); + } + + TEST_METHOD(LeftTrim_WideString_Works) + { + std::wstring_view input = L" hello"; + auto result = left_trim(input); + Assert::AreEqual(std::wstring_view(L"hello"), result); + } + + // right_trim tests + TEST_METHOD(RightTrim_EmptyString_ReturnsEmpty) + { + std::string_view input = ""; + auto result = right_trim(input); + Assert::AreEqual(std::string_view(""), result); + } + + TEST_METHOD(RightTrim_NoWhitespace_ReturnsOriginal) + { + std::string_view input = "hello"; + auto result = right_trim(input); + Assert::AreEqual(std::string_view("hello"), result); + } + + TEST_METHOD(RightTrim_TrailingSpaces_TrimsSpaces) + { + std::string_view input = "hello "; + auto result = right_trim(input); + Assert::AreEqual(std::string_view("hello"), result); + } + + TEST_METHOD(RightTrim_TrailingTabs_TrimsTabs) + { + std::string_view input = "hello\t\t"; + auto result = right_trim(input); + Assert::AreEqual(std::string_view("hello"), result); + } + + TEST_METHOD(RightTrim_TrailingNewlines_TrimsNewlines) + { + std::string_view input = "hello\r\n\n"; + auto result = right_trim(input); + Assert::AreEqual(std::string_view("hello"), result); + } + + TEST_METHOD(RightTrim_LeadingWhitespace_PreservesLeading) + { + std::string_view input = " hello "; + auto result = right_trim(input); + Assert::AreEqual(std::string_view(" hello"), result); + } + + TEST_METHOD(RightTrim_OnlyWhitespace_ReturnsEmpty) + { + std::string_view input = " \t\r\n"; + auto result = right_trim(input); + Assert::AreEqual(std::string_view(""), result); + } + + TEST_METHOD(RightTrim_CustomChars_TrimsSpecified) + { + std::string_view input = "helloxxx"; + auto result = right_trim(input, std::string_view("x")); + Assert::AreEqual(std::string_view("hello"), result); + } + + TEST_METHOD(RightTrim_WideString_Works) + { + std::wstring_view input = L"hello "; + auto result = right_trim(input); + Assert::AreEqual(std::wstring_view(L"hello"), result); + } + + // trim tests + TEST_METHOD(Trim_EmptyString_ReturnsEmpty) + { + std::string_view input = ""; + auto result = trim(input); + Assert::AreEqual(std::string_view(""), result); + } + + TEST_METHOD(Trim_NoWhitespace_ReturnsOriginal) + { + std::string_view input = "hello"; + auto result = trim(input); + Assert::AreEqual(std::string_view("hello"), result); + } + + TEST_METHOD(Trim_BothSides_TrimsBoth) + { + std::string_view input = " hello "; + auto result = trim(input); + Assert::AreEqual(std::string_view("hello"), result); + } + + TEST_METHOD(Trim_MixedWhitespace_TrimsAll) + { + std::string_view input = " \t\r\nhello \t\r\n"; + auto result = trim(input); + Assert::AreEqual(std::string_view("hello"), result); + } + + TEST_METHOD(Trim_InternalWhitespace_Preserved) + { + std::string_view input = " hello world "; + auto result = trim(input); + Assert::AreEqual(std::string_view("hello world"), result); + } + + TEST_METHOD(Trim_OnlyWhitespace_ReturnsEmpty) + { + std::string_view input = " \t\r\n "; + auto result = trim(input); + Assert::AreEqual(std::string_view(""), result); + } + + TEST_METHOD(Trim_CustomChars_TrimsSpecified) + { + std::string_view input = "xxxhelloxxx"; + auto result = trim(input, std::string_view("x")); + Assert::AreEqual(std::string_view("hello"), result); + } + + TEST_METHOD(Trim_WideString_Works) + { + std::wstring_view input = L" hello "; + auto result = trim(input); + Assert::AreEqual(std::wstring_view(L"hello"), result); + } + + // replace_chars tests + TEST_METHOD(ReplaceChars_EmptyString_NoChange) + { + std::string s = ""; + replace_chars(s, std::string_view("abc"), 'x'); + Assert::AreEqual(std::string(""), s); + } + + TEST_METHOD(ReplaceChars_NoMatchingChars_NoChange) + { + std::string s = "hello"; + replace_chars(s, std::string_view("xyz"), '_'); + Assert::AreEqual(std::string("hello"), s); + } + + TEST_METHOD(ReplaceChars_SingleChar_Replaces) + { + std::string s = "hello"; + replace_chars(s, std::string_view("l"), '_'); + Assert::AreEqual(std::string("he__o"), s); + } + + TEST_METHOD(ReplaceChars_MultipleChars_ReplacesAll) + { + std::string s = "hello world"; + replace_chars(s, std::string_view("lo"), '_'); + Assert::AreEqual(std::string("he___ w_r_d"), s); + } + + TEST_METHOD(ReplaceChars_WideString_Works) + { + std::wstring s = L"hello"; + replace_chars(s, std::wstring_view(L"l"), L'_'); + Assert::AreEqual(std::wstring(L"he__o"), s); + } + + // unwide tests + TEST_METHOD(Unwide_EmptyString_ReturnsEmpty) + { + std::wstring input = L""; + auto result = unwide(input); + Assert::AreEqual(std::string(""), result); + } + + TEST_METHOD(Unwide_AsciiString_Converts) + { + std::wstring input = L"hello"; + auto result = unwide(input); + Assert::AreEqual(std::string("hello"), result); + } + + TEST_METHOD(Unwide_WithNumbers_Converts) + { + std::wstring input = L"test123"; + auto result = unwide(input); + Assert::AreEqual(std::string("test123"), result); + } + + TEST_METHOD(Unwide_WithSpecialChars_Converts) + { + std::wstring input = L"test!@#$%"; + auto result = unwide(input); + Assert::AreEqual(std::string("test!@#$%"), result); + } + + TEST_METHOD(Unwide_MixedCase_PreservesCase) + { + std::wstring input = L"HeLLo WoRLd"; + auto result = unwide(input); + Assert::AreEqual(std::string("HeLLo WoRLd"), result); + } + + TEST_METHOD(Unwide_LongString_Works) + { + std::wstring input = L"This is a longer string with multiple words and punctuation!"; + auto result = unwide(input); + Assert::AreEqual(std::string("This is a longer string with multiple words and punctuation!"), result); + } + }; +} diff --git a/src/common/UnitTests-CommonUtils/TestHelpers.h b/src/common/UnitTests-CommonUtils/TestHelpers.h new file mode 100644 index 0000000000..c7f0a45e33 --- /dev/null +++ b/src/common/UnitTests-CommonUtils/TestHelpers.h @@ -0,0 +1,192 @@ +#pragma once + +#include "pch.h" +#include +#include +#include +#include + +namespace TestHelpers +{ + // RAII helper for creating and cleaning up temporary files + class TempFile + { + public: + TempFile(const std::wstring& content = L"", const std::wstring& extension = L".txt") + { + wchar_t tempPath[MAX_PATH]; + GetTempPathW(MAX_PATH, tempPath); + + // Generate a unique filename + std::random_device rd; + std::mt19937 gen(rd()); + std::uniform_int_distribution<> dis(10000, 99999); + + m_path = std::wstring(tempPath) + L"test_" + std::to_wstring(dis(gen)) + extension; + + if (!content.empty()) + { + std::wofstream file(m_path); + file << content; + } + } + + ~TempFile() + { + if (std::filesystem::exists(m_path)) + { + std::filesystem::remove(m_path); + } + } + + TempFile(const TempFile&) = delete; + TempFile& operator=(const TempFile&) = delete; + + const std::wstring& path() const { return m_path; } + + void write(const std::string& content) + { + std::ofstream file(m_path, std::ios::binary); + file << content; + } + + void write(const std::wstring& content) + { + std::wofstream file(m_path); + file << content; + } + + std::wstring read() + { + std::wifstream file(m_path); + return std::wstring((std::istreambuf_iterator(file)), + std::istreambuf_iterator()); + } + + private: + std::wstring m_path; + }; + + // RAII helper for creating and cleaning up temporary directories + class TempDirectory + { + public: + TempDirectory() + { + wchar_t tempPath[MAX_PATH]; + GetTempPathW(MAX_PATH, tempPath); + + std::random_device rd; + std::mt19937 gen(rd()); + std::uniform_int_distribution<> dis(10000, 99999); + + m_path = std::wstring(tempPath) + L"testdir_" + std::to_wstring(dis(gen)); + std::filesystem::create_directories(m_path); + } + + ~TempDirectory() + { + if (std::filesystem::exists(m_path)) + { + std::filesystem::remove_all(m_path); + } + } + + TempDirectory(const TempDirectory&) = delete; + TempDirectory& operator=(const TempDirectory&) = delete; + + const std::wstring& path() const { return m_path; } + + private: + std::wstring m_path; + }; + + // Registry test key path - use HKCU for non-elevated tests + inline const std::wstring TestRegistryPath = L"Software\\PowerToys\\UnitTests"; + + // RAII helper for registry key creation/cleanup + class TestRegistryKey + { + public: + TestRegistryKey(const std::wstring& subKey = L"") + { + m_path = TestRegistryPath; + if (!subKey.empty()) + { + m_path += L"\\" + subKey; + } + + HKEY key; + if (RegCreateKeyExW(HKEY_CURRENT_USER, m_path.c_str(), 0, nullptr, + REG_OPTION_NON_VOLATILE, KEY_ALL_ACCESS, nullptr, + &key, nullptr) == ERROR_SUCCESS) + { + RegCloseKey(key); + m_created = true; + } + } + + ~TestRegistryKey() + { + if (m_created) + { + RegDeleteTreeW(HKEY_CURRENT_USER, m_path.c_str()); + } + } + + TestRegistryKey(const TestRegistryKey&) = delete; + TestRegistryKey& operator=(const TestRegistryKey&) = delete; + + bool isValid() const { return m_created; } + const std::wstring& path() const { return m_path; } + + bool setStringValue(const std::wstring& name, const std::wstring& value) + { + HKEY key; + if (RegOpenKeyExW(HKEY_CURRENT_USER, m_path.c_str(), 0, KEY_SET_VALUE, &key) != ERROR_SUCCESS) + { + return false; + } + + auto result = RegSetValueExW(key, name.c_str(), 0, REG_SZ, + reinterpret_cast(value.c_str()), + static_cast((value.length() + 1) * sizeof(wchar_t))); + RegCloseKey(key); + return result == ERROR_SUCCESS; + } + + bool setDwordValue(const std::wstring& name, DWORD value) + { + HKEY key; + if (RegOpenKeyExW(HKEY_CURRENT_USER, m_path.c_str(), 0, KEY_SET_VALUE, &key) != ERROR_SUCCESS) + { + return false; + } + + auto result = RegSetValueExW(key, name.c_str(), 0, REG_DWORD, + reinterpret_cast(&value), sizeof(DWORD)); + RegCloseKey(key); + return result == ERROR_SUCCESS; + } + + private: + std::wstring m_path; + bool m_created = false; + }; + + // Helper to wait for a condition with timeout + template + bool WaitFor(Predicate pred, std::chrono::milliseconds timeout = std::chrono::milliseconds(5000)) + { + auto start = std::chrono::steady_clock::now(); + while (!pred()) + { + if (std::chrono::steady_clock::now() - start > timeout) + { + return false; + } + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + return true; + } +} diff --git a/src/common/UnitTests-CommonUtils/TestStubs.cpp b/src/common/UnitTests-CommonUtils/TestStubs.cpp new file mode 100644 index 0000000000..5c80c39101 --- /dev/null +++ b/src/common/UnitTests-CommonUtils/TestStubs.cpp @@ -0,0 +1,14 @@ +#include "pch.h" +#include +#include +#include + +std::shared_ptr Logger::logger = spdlog::null_logger_mt("Common.Utils.UnitTests"); + +namespace PTSettingsHelper +{ + std::wstring get_root_save_folder_location() + { + return L""; + } +} diff --git a/src/common/UnitTests-CommonUtils/Threading.Tests.cpp b/src/common/UnitTests-CommonUtils/Threading.Tests.cpp new file mode 100644 index 0000000000..2c587ad0ca --- /dev/null +++ b/src/common/UnitTests-CommonUtils/Threading.Tests.cpp @@ -0,0 +1,336 @@ +#include "pch.h" +#include "TestHelpers.h" +#include +#include +#include + +using namespace Microsoft::VisualStudio::CppUnitTestFramework; + +namespace UnitTestsCommonUtils +{ + TEST_CLASS(OnThreadExecutorTests) + { + public: + TEST_METHOD(Constructor_CreatesInstance) + { + OnThreadExecutor executor; + // Should not crash + Assert::IsTrue(true); + } + + TEST_METHOD(Submit_SingleTask_Executes) + { + OnThreadExecutor executor; + std::atomic executed{ false }; + + auto future = executor.submit(OnThreadExecutor::task_t([&executed]() { + executed = true; + })); + + future.wait(); + Assert::IsTrue(executed); + } + + TEST_METHOD(Submit_MultipleTasks_ExecutesAll) + { + OnThreadExecutor executor; + std::atomic counter{ 0 }; + + std::vector> futures; + for (int i = 0; i < 10; ++i) + { + futures.push_back(executor.submit(OnThreadExecutor::task_t([&counter]() { + counter++; + }))); + } + + for (auto& f : futures) + { + f.wait(); + } + + Assert::AreEqual(10, counter.load()); + } + + TEST_METHOD(Submit_TasksExecuteInOrder) + { + OnThreadExecutor executor; + std::vector order; + std::mutex orderMutex; + + std::vector> futures; + for (int i = 0; i < 5; ++i) + { + futures.push_back(executor.submit(OnThreadExecutor::task_t([&order, &orderMutex, i]() { + std::lock_guard lock(orderMutex); + order.push_back(i); + }))); + } + + for (auto& f : futures) + { + f.wait(); + } + + Assert::AreEqual(static_cast(5), order.size()); + for (int i = 0; i < 5; ++i) + { + Assert::AreEqual(i, order[i]); + } + } + + TEST_METHOD(Submit_TaskReturnsResult) + { + OnThreadExecutor executor; + std::atomic result{ 0 }; + + auto future = executor.submit(OnThreadExecutor::task_t([&result]() { + result = 42; + })); + + future.wait(); + Assert::AreEqual(42, result.load()); + } + + TEST_METHOD(Cancel_ClearsPendingTasks) + { + OnThreadExecutor executor; + std::atomic counter{ 0 }; + + // Submit a slow task first + executor.submit(OnThreadExecutor::task_t([&counter]() { + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + counter++; + })); + + // Submit more tasks + for (int i = 0; i < 5; ++i) + { + executor.submit(OnThreadExecutor::task_t([&counter]() { + counter++; + })); + } + + // Cancel pending tasks + executor.cancel(); + + // Wait a bit for any running task to complete + std::this_thread::sleep_for(std::chrono::milliseconds(200)); + + // Not all tasks should have executed + Assert::IsTrue(counter < 6); + } + + TEST_METHOD(Destructor_WaitsForCompletion) + { + std::atomic completed{ false }; + std::future future; + + { + OnThreadExecutor executor; + future = executor.submit(OnThreadExecutor::task_t([&completed]() { + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + completed = true; + })); + future.wait(); + } // Destructor no longer required to wait for completion + + Assert::IsTrue(completed); + } + + TEST_METHOD(Submit_AfterCancel_StillWorks) + { + OnThreadExecutor executor; + std::atomic counter{ 0 }; + + executor.submit(OnThreadExecutor::task_t([&counter]() { + counter++; + })); + executor.cancel(); + + auto future = executor.submit(OnThreadExecutor::task_t([&counter]() { + counter = 42; + })); + future.wait(); + + Assert::AreEqual(42, counter.load()); + } + }; + + TEST_CLASS(EventWaiterTests) + { + public: + TEST_METHOD(Constructor_CreatesInstance) + { + EventWaiter waiter; + Assert::IsFalse(waiter.is_listening()); + } + + TEST_METHOD(Start_ValidEvent_ReturnsTrue) + { + EventWaiter waiter; + bool result = waiter.start(L"TestEvent_Start", [](DWORD) {}); + Assert::IsTrue(result); + Assert::IsTrue(waiter.is_listening()); + waiter.stop(); + } + + TEST_METHOD(Start_AlreadyListening_ReturnsFalse) + { + EventWaiter waiter; + waiter.start(L"TestEvent_Double1", [](DWORD) {}); + bool result = waiter.start(L"TestEvent_Double2", [](DWORD) {}); + Assert::IsFalse(result); + waiter.stop(); + } + + TEST_METHOD(Stop_WhileListening_StopsListening) + { + EventWaiter waiter; + waiter.start(L"TestEvent_Stop", [](DWORD) {}); + Assert::IsTrue(waiter.is_listening()); + + waiter.stop(); + Assert::IsFalse(waiter.is_listening()); + } + + TEST_METHOD(Stop_WhenNotListening_DoesNotCrash) + { + EventWaiter waiter; + waiter.stop(); // Should not crash + Assert::IsFalse(waiter.is_listening()); + } + + TEST_METHOD(Stop_CalledMultipleTimes_DoesNotCrash) + { + EventWaiter waiter; + waiter.start(L"TestEvent_MultiStop", [](DWORD) {}); + waiter.stop(); + waiter.stop(); + waiter.stop(); + Assert::IsFalse(waiter.is_listening()); + } + + TEST_METHOD(Callback_EventSignaled_CallsCallback) + { + EventWaiter waiter; + std::atomic called{ false }; + std::atomic errorCode{ 0xFFFFFFFF }; + + // Create a named event we can signal + std::wstring eventName = L"TestEvent_Callback_" + std::to_wstring(GetCurrentProcessId()); + HANDLE signalEvent = CreateEventW(nullptr, FALSE, FALSE, eventName.c_str()); + Assert::IsNotNull(signalEvent); + + waiter.start(eventName, [&called, &errorCode](DWORD err) { + errorCode = err; + called = true; + }); + + // Signal the event + SetEvent(signalEvent); + + // Wait for callback + bool waitResult = TestHelpers::WaitFor([&called]() { return called.load(); }, std::chrono::milliseconds(1000)); + + waiter.stop(); + CloseHandle(signalEvent); + + Assert::IsTrue(waitResult); + Assert::AreEqual(static_cast(ERROR_SUCCESS), errorCode.load()); + } + + TEST_METHOD(Destructor_StopsListening) + { + std::atomic isListening{ false }; + { + EventWaiter waiter; + waiter.start(L"TestEvent_Destructor", [](DWORD) {}); + isListening = waiter.is_listening(); + } + // After destruction, the waiter should have stopped + Assert::IsTrue(isListening); + } + + TEST_METHOD(IsListening_InitialState_ReturnsFalse) + { + EventWaiter waiter; + Assert::IsFalse(waiter.is_listening()); + } + + TEST_METHOD(IsListening_AfterStart_ReturnsTrue) + { + EventWaiter waiter; + waiter.start(L"TestEvent_IsListening", [](DWORD) {}); + Assert::IsTrue(waiter.is_listening()); + waiter.stop(); + } + + TEST_METHOD(IsListening_AfterStop_ReturnsFalse) + { + EventWaiter waiter; + waiter.start(L"TestEvent_AfterStop", [](DWORD) {}); + waiter.stop(); + Assert::IsFalse(waiter.is_listening()); + } + }; + + TEST_CLASS(EventLockerTests) + { + public: + TEST_METHOD(Get_ValidEventName_ReturnsLocker) + { + std::wstring eventName = L"TestEventLocker_" + std::to_wstring(GetCurrentProcessId()); + auto locker = EventLocker::Get(eventName); + Assert::IsTrue(locker.has_value()); + } + + TEST_METHOD(Get_UniqueNames_CreatesSeparateLockers) + { + auto locker1 = EventLocker::Get(L"TestEventLocker1_" + std::to_wstring(GetCurrentProcessId())); + auto locker2 = EventLocker::Get(L"TestEventLocker2_" + std::to_wstring(GetCurrentProcessId())); + Assert::IsTrue(locker1.has_value()); + Assert::IsTrue(locker2.has_value()); + } + + TEST_METHOD(Destructor_CleansUpHandle) + { + std::wstring eventName = L"TestEventLockerCleanup_" + std::to_wstring(GetCurrentProcessId()); + { + auto locker = EventLocker::Get(eventName); + Assert::IsTrue(locker.has_value()); + } + // After destruction, the event should be cleaned up + // Creating a new one should succeed + auto newLocker = EventLocker::Get(eventName); + Assert::IsTrue(newLocker.has_value()); + } + + TEST_METHOD(MoveConstructor_TransfersOwnership) + { + std::wstring eventName = L"TestEventLockerMove_" + std::to_wstring(GetCurrentProcessId()); + auto locker1 = EventLocker::Get(eventName); + Assert::IsTrue(locker1.has_value()); + + EventLocker locker2 = std::move(*locker1); + // Move should transfer ownership without crash + Assert::IsTrue(true); + } + + TEST_METHOD(MoveAssignment_TransfersOwnership) + { + std::wstring eventName1 = L"TestEventLockerMoveAssign1_" + std::to_wstring(GetCurrentProcessId()); + std::wstring eventName2 = L"TestEventLockerMoveAssign2_" + std::to_wstring(GetCurrentProcessId()); + + auto locker1 = EventLocker::Get(eventName1); + auto locker2 = EventLocker::Get(eventName2); + + Assert::IsTrue(locker1.has_value()); + Assert::IsTrue(locker2.has_value()); + + *locker1 = std::move(*locker2); + // Should not crash + Assert::IsTrue(true); + } + }; +} diff --git a/src/common/UnitTests-CommonUtils/TimeUtils.Tests.cpp b/src/common/UnitTests-CommonUtils/TimeUtils.Tests.cpp new file mode 100644 index 0000000000..4de329ff4f --- /dev/null +++ b/src/common/UnitTests-CommonUtils/TimeUtils.Tests.cpp @@ -0,0 +1,248 @@ +#include "pch.h" +#include "TestHelpers.h" +#include + +using namespace Microsoft::VisualStudio::CppUnitTestFramework; + +namespace UnitTestsCommonUtils +{ + TEST_CLASS(TimeUtilsTests) + { + public: + // to_string tests + TEST_METHOD(ToString_ZeroTime_ReturnsZero) + { + time_t t = 0; + auto result = timeutil::to_string(t); + Assert::AreEqual(std::wstring(L"0"), result); + } + + TEST_METHOD(ToString_PositiveTime_ReturnsString) + { + time_t t = 1234567890; + auto result = timeutil::to_string(t); + Assert::AreEqual(std::wstring(L"1234567890"), result); + } + + TEST_METHOD(ToString_LargeTime_ReturnsString) + { + time_t t = 1700000000; + auto result = timeutil::to_string(t); + Assert::AreEqual(std::wstring(L"1700000000"), result); + } + + // from_string tests + TEST_METHOD(FromString_ZeroString_ReturnsZero) + { + auto result = timeutil::from_string(L"0"); + Assert::IsTrue(result.has_value()); + Assert::AreEqual(static_cast(0), result.value()); + } + + TEST_METHOD(FromString_ValidNumber_ReturnsTime) + { + auto result = timeutil::from_string(L"1234567890"); + Assert::IsTrue(result.has_value()); + Assert::AreEqual(static_cast(1234567890), result.value()); + } + + TEST_METHOD(FromString_InvalidString_ReturnsNullopt) + { + auto result = timeutil::from_string(L"invalid"); + Assert::IsFalse(result.has_value()); + } + + TEST_METHOD(FromString_EmptyString_ReturnsNullopt) + { + auto result = timeutil::from_string(L""); + Assert::IsFalse(result.has_value()); + } + + TEST_METHOD(FromString_MixedAlphaNumeric_ReturnsNullopt) + { + auto result = timeutil::from_string(L"123abc"); + Assert::IsFalse(result.has_value()); + } + + TEST_METHOD(FromString_NegativeNumber_ReturnsNullopt) + { + auto result = timeutil::from_string(L"-1"); + Assert::IsFalse(result.has_value()); + } + + // Roundtrip test + TEST_METHOD(ToStringFromString_Roundtrip_Works) + { + time_t original = 1609459200; // 2021-01-01 00:00:00 UTC + auto str = timeutil::to_string(original); + auto result = timeutil::from_string(str); + Assert::IsTrue(result.has_value()); + Assert::AreEqual(original, result.value()); + } + + // now tests + TEST_METHOD(Now_ReturnsReasonableTime) + { + auto result = timeutil::now(); + // Should be after 2020 and before 2100 + Assert::IsTrue(result > 1577836800); // 2020-01-01 + Assert::IsTrue(result < 4102444800); // 2100-01-01 + } + + TEST_METHOD(Now_TwoCallsAreCloseInTime) + { + auto first = timeutil::now(); + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + auto second = timeutil::now(); + // Difference should be less than 2 seconds + Assert::IsTrue(second >= first); + Assert::IsTrue(second - first < 2); + } + + // diff::in_seconds tests + TEST_METHOD(DiffInSeconds_SameTime_ReturnsZero) + { + time_t t = 1000000; + auto result = timeutil::diff::in_seconds(t, t); + Assert::AreEqual(static_cast(0), result); + } + + TEST_METHOD(DiffInSeconds_OneDifference_ReturnsOne) + { + time_t to = 1000001; + time_t from = 1000000; + auto result = timeutil::diff::in_seconds(to, from); + Assert::AreEqual(static_cast(1), result); + } + + TEST_METHOD(DiffInSeconds_60Seconds_Returns60) + { + time_t to = 1000060; + time_t from = 1000000; + auto result = timeutil::diff::in_seconds(to, from); + Assert::AreEqual(static_cast(60), result); + } + + TEST_METHOD(DiffInSeconds_NegativeDiff_ReturnsNegative) + { + time_t to = 1000000; + time_t from = 1000060; + auto result = timeutil::diff::in_seconds(to, from); + Assert::AreEqual(static_cast(-60), result); + } + + // diff::in_minutes tests + TEST_METHOD(DiffInMinutes_SameTime_ReturnsZero) + { + time_t t = 1000000; + auto result = timeutil::diff::in_minutes(t, t); + Assert::AreEqual(static_cast(0), result); + } + + TEST_METHOD(DiffInMinutes_OneMinute_ReturnsOne) + { + time_t to = 1000060; + time_t from = 1000000; + auto result = timeutil::diff::in_minutes(to, from); + Assert::AreEqual(static_cast(1), result); + } + + TEST_METHOD(DiffInMinutes_60Minutes_Returns60) + { + time_t to = 1003600; + time_t from = 1000000; + auto result = timeutil::diff::in_minutes(to, from); + Assert::AreEqual(static_cast(60), result); + } + + TEST_METHOD(DiffInMinutes_LessThanMinute_ReturnsZero) + { + time_t to = 1000059; + time_t from = 1000000; + auto result = timeutil::diff::in_minutes(to, from); + Assert::AreEqual(static_cast(0), result); + } + + // diff::in_hours tests + TEST_METHOD(DiffInHours_SameTime_ReturnsZero) + { + time_t t = 1000000; + auto result = timeutil::diff::in_hours(t, t); + Assert::AreEqual(static_cast(0), result); + } + + TEST_METHOD(DiffInHours_OneHour_ReturnsOne) + { + time_t to = 1003600; + time_t from = 1000000; + auto result = timeutil::diff::in_hours(to, from); + Assert::AreEqual(static_cast(1), result); + } + + TEST_METHOD(DiffInHours_24Hours_Returns24) + { + time_t to = 1086400; + time_t from = 1000000; + auto result = timeutil::diff::in_hours(to, from); + Assert::AreEqual(static_cast(24), result); + } + + TEST_METHOD(DiffInHours_LessThanHour_ReturnsZero) + { + time_t to = 1003599; + time_t from = 1000000; + auto result = timeutil::diff::in_hours(to, from); + Assert::AreEqual(static_cast(0), result); + } + + // diff::in_days tests + TEST_METHOD(DiffInDays_SameTime_ReturnsZero) + { + time_t t = 1000000; + auto result = timeutil::diff::in_days(t, t); + Assert::AreEqual(static_cast(0), result); + } + + TEST_METHOD(DiffInDays_OneDay_ReturnsOne) + { + time_t to = 1086400; + time_t from = 1000000; + auto result = timeutil::diff::in_days(to, from); + Assert::AreEqual(static_cast(1), result); + } + + TEST_METHOD(DiffInDays_7Days_Returns7) + { + time_t to = 1604800; + time_t from = 1000000; + auto result = timeutil::diff::in_days(to, from); + Assert::AreEqual(static_cast(7), result); + } + + TEST_METHOD(DiffInDays_LessThanDay_ReturnsZero) + { + time_t to = 1086399; + time_t from = 1000000; + auto result = timeutil::diff::in_days(to, from); + Assert::AreEqual(static_cast(0), result); + } + + // format_as_local tests + TEST_METHOD(FormatAsLocal_YearFormat_ReturnsYear) + { + time_t t = 1609459200; // 2021-01-01 00:00:00 UTC + auto result = timeutil::format_as_local("%Y", t); + // Result depends on local timezone, but year should be 2020 or 2021 + Assert::IsTrue(result == "2020" || result == "2021"); + } + + TEST_METHOD(FormatAsLocal_DateFormat_ReturnsDate) + { + time_t t = 0; // 1970-01-01 00:00:00 UTC + auto result = timeutil::format_as_local("%Y-%m-%d", t); + // Result should be a date around 1970-01-01 depending on timezone + Assert::IsTrue(result.length() == 10); // YYYY-MM-DD format + Assert::IsTrue(result.substr(0, 4) == "1969" || result.substr(0, 4) == "1970"); + } + }; +} diff --git a/src/common/UnitTests-CommonUtils/UnhandledException.Tests.cpp b/src/common/UnitTests-CommonUtils/UnhandledException.Tests.cpp new file mode 100644 index 0000000000..4bac3b1ee7 --- /dev/null +++ b/src/common/UnitTests-CommonUtils/UnhandledException.Tests.cpp @@ -0,0 +1,210 @@ +#include "pch.h" +#include "TestHelpers.h" +#include + +using namespace Microsoft::VisualStudio::CppUnitTestFramework; + +namespace UnitTestsCommonUtils +{ + TEST_CLASS(UnhandledExceptionTests) + { + public: + // exceptionDescription tests + TEST_METHOD(ExceptionDescription_AccessViolation_ReturnsDescription) + { + auto result = exceptionDescription(EXCEPTION_ACCESS_VIOLATION); + Assert::IsTrue(result && *result != '\0'); + // Should contain meaningful description + std::string desc{ result }; + Assert::IsTrue(desc.find("ACCESS") != std::string::npos || + desc.find("access") != std::string::npos || + desc.find("violation") != std::string::npos || + desc.length() > 0); + } + + TEST_METHOD(ExceptionDescription_StackOverflow_ReturnsDescription) + { + auto result = exceptionDescription(EXCEPTION_STACK_OVERFLOW); + Assert::IsTrue(result && *result != '\0'); + } + + TEST_METHOD(ExceptionDescription_DivideByZero_ReturnsDescription) + { + auto result = exceptionDescription(EXCEPTION_INT_DIVIDE_BY_ZERO); + Assert::IsTrue(result && *result != '\0'); + } + + TEST_METHOD(ExceptionDescription_IllegalInstruction_ReturnsDescription) + { + auto result = exceptionDescription(EXCEPTION_ILLEGAL_INSTRUCTION); + Assert::IsTrue(result && *result != '\0'); + } + + TEST_METHOD(ExceptionDescription_ArrayBoundsExceeded_ReturnsDescription) + { + auto result = exceptionDescription(EXCEPTION_ARRAY_BOUNDS_EXCEEDED); + Assert::IsTrue(result && *result != '\0'); + } + + TEST_METHOD(ExceptionDescription_Breakpoint_ReturnsDescription) + { + auto result = exceptionDescription(EXCEPTION_BREAKPOINT); + Assert::IsTrue(result && *result != '\0'); + } + + TEST_METHOD(ExceptionDescription_SingleStep_ReturnsDescription) + { + auto result = exceptionDescription(EXCEPTION_SINGLE_STEP); + Assert::IsTrue(result && *result != '\0'); + } + + TEST_METHOD(ExceptionDescription_FloatDivideByZero_ReturnsDescription) + { + auto result = exceptionDescription(EXCEPTION_FLT_DIVIDE_BY_ZERO); + Assert::IsTrue(result && *result != '\0'); + } + + TEST_METHOD(ExceptionDescription_FloatOverflow_ReturnsDescription) + { + auto result = exceptionDescription(EXCEPTION_FLT_OVERFLOW); + Assert::IsTrue(result && *result != '\0'); + } + + TEST_METHOD(ExceptionDescription_FloatUnderflow_ReturnsDescription) + { + auto result = exceptionDescription(EXCEPTION_FLT_UNDERFLOW); + Assert::IsTrue(result && *result != '\0'); + } + + TEST_METHOD(ExceptionDescription_FloatInvalidOperation_ReturnsDescription) + { + auto result = exceptionDescription(EXCEPTION_FLT_INVALID_OPERATION); + Assert::IsTrue(result && *result != '\0'); + } + + TEST_METHOD(ExceptionDescription_PrivilegedInstruction_ReturnsDescription) + { + auto result = exceptionDescription(EXCEPTION_PRIV_INSTRUCTION); + Assert::IsTrue(result && *result != '\0'); + } + + TEST_METHOD(ExceptionDescription_InPageError_ReturnsDescription) + { + auto result = exceptionDescription(EXCEPTION_IN_PAGE_ERROR); + Assert::IsTrue(result && *result != '\0'); + } + + TEST_METHOD(ExceptionDescription_UnknownCode_ReturnsDescription) + { + auto result = exceptionDescription(0x12345678); + // Should return something (possibly "Unknown exception" or similar) + Assert::IsTrue(result && *result != '\0'); + } + + TEST_METHOD(ExceptionDescription_ZeroCode_ReturnsDescription) + { + auto result = exceptionDescription(0); + // Should handle zero gracefully + Assert::IsTrue(result && *result != '\0'); + } + + // GetFilenameStart tests (if accessible) + TEST_METHOD(GetFilenameStart_ValidPath_ReturnsFilename) + { + wchar_t path[] = L"C:\\folder\\subfolder\\file.exe"; + int start = GetFilenameStart(path); + + Assert::IsTrue(start >= 0); + Assert::AreEqual(std::wstring(L"file.exe"), std::wstring(path + start)); + } + + TEST_METHOD(GetFilenameStart_NoPath_ReturnsOriginal) + { + wchar_t path[] = L"file.exe"; + int start = GetFilenameStart(path); + + Assert::IsTrue(start >= 0); + Assert::AreEqual(std::wstring(L"file.exe"), std::wstring(path + start)); + } + + TEST_METHOD(GetFilenameStart_TrailingBackslash_ReturnsEmpty) + { + wchar_t path[] = L"C:\\folder\\"; + int start = GetFilenameStart(path); + + // Should point to empty string after last backslash + Assert::IsTrue(start >= 0); + } + + TEST_METHOD(GetFilenameStart_NullPath_HandlesGracefully) + { + // This might crash or return null depending on implementation + // Just document the behavior + int start = GetFilenameStart(nullptr); + (void)start; + // Result is implementation-defined for null input + Assert::IsTrue(true); + } + + // Thread safety tests + TEST_METHOD(ExceptionDescription_ThreadSafe) + { + std::vector threads; + std::atomic successCount{ 0 }; + + for (int i = 0; i < 10; ++i) + { + threads.emplace_back([&successCount]() { + for (int j = 0; j < 10; ++j) + { + auto desc = exceptionDescription(EXCEPTION_ACCESS_VIOLATION); + if (desc && *desc != '\0') + { + successCount++; + } + } + }); + } + + for (auto& t : threads) + { + t.join(); + } + + Assert::AreEqual(100, successCount.load()); + } + + // All exception codes test + TEST_METHOD(ExceptionDescription_AllCommonCodes_ReturnDescriptions) + { + std::vector codes = { + EXCEPTION_ACCESS_VIOLATION, + EXCEPTION_ARRAY_BOUNDS_EXCEEDED, + EXCEPTION_BREAKPOINT, + EXCEPTION_DATATYPE_MISALIGNMENT, + EXCEPTION_FLT_DENORMAL_OPERAND, + EXCEPTION_FLT_DIVIDE_BY_ZERO, + EXCEPTION_FLT_INEXACT_RESULT, + EXCEPTION_FLT_INVALID_OPERATION, + EXCEPTION_FLT_OVERFLOW, + EXCEPTION_FLT_STACK_CHECK, + EXCEPTION_FLT_UNDERFLOW, + EXCEPTION_ILLEGAL_INSTRUCTION, + EXCEPTION_IN_PAGE_ERROR, + EXCEPTION_INT_DIVIDE_BY_ZERO, + EXCEPTION_INT_OVERFLOW, + EXCEPTION_INVALID_DISPOSITION, + EXCEPTION_NONCONTINUABLE_EXCEPTION, + EXCEPTION_PRIV_INSTRUCTION, + EXCEPTION_SINGLE_STEP, + EXCEPTION_STACK_OVERFLOW + }; + + for (DWORD code : codes) + { + auto desc = exceptionDescription(code); + Assert::IsTrue(desc && *desc != '\0', (L"Empty description for code: " + std::to_wstring(code)).c_str()); + } + } + }; +} diff --git a/src/common/UnitTests-CommonUtils/UnitTests-CommonUtils.rc b/src/common/UnitTests-CommonUtils/UnitTests-CommonUtils.rc new file mode 100644 index 0000000000..1242bfe580 --- /dev/null +++ b/src/common/UnitTests-CommonUtils/UnitTests-CommonUtils.rc @@ -0,0 +1,36 @@ +#include +#include "resource.h" +#include "../version/version.h" + +1 VERSIONINFO +FILEVERSION FILE_VERSION +PRODUCTVERSION PRODUCT_VERSION +FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG +FILEFLAGS VS_FF_DEBUG +#else +FILEFLAGS 0x0L +#endif +FILEOS VOS_NT_WINDOWS32 +FILETYPE VFT_DLL +FILESUBTYPE VFT2_UNKNOWN +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904b0" // US English (0x0409), Unicode (0x04B0) charset + BEGIN + VALUE "CompanyName", COMPANY_NAME + VALUE "FileDescription", FILE_DESCRIPTION + VALUE "FileVersion", FILE_VERSION_STRING + VALUE "InternalName", INTERNAL_NAME + VALUE "LegalCopyright", COPYRIGHT_NOTE + VALUE "OriginalFilename", ORIGINAL_FILENAME + VALUE "ProductName", PRODUCT_NAME + VALUE "ProductVersion", PRODUCT_VERSION_STRING + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1200 // US English (0x0409), Unicode (1200) charset + END +END diff --git a/src/common/UnitTests-CommonUtils/UnitTests-CommonUtils.vcxproj b/src/common/UnitTests-CommonUtils/UnitTests-CommonUtils.vcxproj new file mode 100644 index 0000000000..9c4bcde7c0 --- /dev/null +++ b/src/common/UnitTests-CommonUtils/UnitTests-CommonUtils.vcxproj @@ -0,0 +1,96 @@ + + + + + 16.0 + {8B5CFB38-CCBA-40A8-AD7A-89C57B070884} + Win32Proj + UnitTestsCommonUtils + NativeUnitTestProject + Common.Utils.UnitTests + + + + DynamicLibrary + false + v143 + $(SolutionDir)$(Platform)\$(Configuration)\tests\UnitTestsCommonUtils\ + + + + + + + + + + + + + ..\;..\utils;..\Telemetry;..\..\;..\..\..\deps\;..\..\..\deps\spdlog\include;..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\include;$(VCInstallDir)UnitTest\include;%(AdditionalIncludeDirectories) + stdcpp23 + SPDLOG_WCHAR_TO_UTF8_SUPPORT;SPDLOG_HEADER_ONLY;%(PreprocessorDefinitions) + + + $(VCInstallDir)UnitTest\lib;%(AdditionalLibraryDirectories) + RuntimeObject.lib;Msi.lib;Shlwapi.lib;%(AdditionalDependencies) + + + + + Create + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. + + + + + diff --git a/src/common/UnitTests-CommonUtils/UnitTests-CommonUtils.vcxproj.filters b/src/common/UnitTests-CommonUtils/UnitTests-CommonUtils.vcxproj.filters new file mode 100644 index 0000000000..c642faa4b5 --- /dev/null +++ b/src/common/UnitTests-CommonUtils/UnitTests-CommonUtils.vcxproj.filters @@ -0,0 +1,143 @@ + + + + + {4FC737F1-C7A5-4376-A066-2A32D752A2FF} + cpp;c;cc;cxx;def;odl;idl;hpj;bat;asm;asmx + + + {93995380-89BD-4b04-88EB-625FBE52EBFB} + h;hh;hpp;hxx;hm;inl;inc;ipp;xsd + + + {67DA6AB6-F800-4c08-8B7A-83BB121AAD01} + rc;ico;cur;bmp;dlg;rc2;rct;bin;rgs;gif;jpg;jpeg;jpe;resx;tiff;tif;png;wav;mfcribbon-ms + + + {A1B2C3D4-E5F6-4A5B-8C9D-0E1F2A3B4C5D} + + + {B2C3D4E5-F6A7-4B6C-9D0E-1F2A3B4C5D6E} + + + {C3D4E5F6-A7B8-4C7D-0E1F-2A3B4C5D6E7F} + + + {D4E5F6A7-B8C9-4D8E-1F2A-3B4C5D6E7F8A} + + + {E5F6A7B8-C9D0-4E9F-2A3B-4C5D6E7F8A9B} + + + + + Source Files + + + Source Files\Pure Functions + + + Source Files\Pure Functions + + + Source Files\Pure Functions + + + Source Files\Pure Functions + + + Source Files\Pure Functions + + + Source Files\Pure Functions + + + Source Files\Pure Functions + + + Source Files\Pure Functions + + + Source Files\Threading + + + Source Files\Threading + + + Source Files\Threading + + + Source Files\Process + + + Source Files\Process + + + Source Files\Process + + + Source Files\Process + + + Source Files\Process + + + Source Files\Process + + + Source Files\Registry + + + Source Files\Registry + + + Source Files\Registry + + + Source Files\Integration + + + Source Files\Integration + + + Source Files\Integration + + + Source Files\Integration + + + Source Files\Integration + + + Source Files\Integration + + + Source Files\Integration + + + Source Files\Integration + + + Source Files\Integration + + + + + Header Files + + + Header Files + + + Header Files + + + + + Resource Files + + + + + + diff --git a/src/common/UnitTests-CommonUtils/WinApiError.Tests.cpp b/src/common/UnitTests-CommonUtils/WinApiError.Tests.cpp new file mode 100644 index 0000000000..e51d1f5862 --- /dev/null +++ b/src/common/UnitTests-CommonUtils/WinApiError.Tests.cpp @@ -0,0 +1,130 @@ +#include "pch.h" +#include "TestHelpers.h" +#include + +using namespace Microsoft::VisualStudio::CppUnitTestFramework; + +namespace UnitTestsCommonUtils +{ + TEST_CLASS(WinApiErrorTests) + { + public: + // get_last_error_message tests + TEST_METHOD(GetLastErrorMessage_Success_ReturnsMessage) + { + auto result = get_last_error_message(ERROR_SUCCESS); + Assert::IsTrue(result.has_value()); + Assert::IsFalse(result->empty()); + } + + TEST_METHOD(GetLastErrorMessage_FileNotFound_ReturnsMessage) + { + auto result = get_last_error_message(ERROR_FILE_NOT_FOUND); + Assert::IsTrue(result.has_value()); + Assert::IsFalse(result->empty()); + } + + TEST_METHOD(GetLastErrorMessage_AccessDenied_ReturnsMessage) + { + auto result = get_last_error_message(ERROR_ACCESS_DENIED); + Assert::IsTrue(result.has_value()); + Assert::IsFalse(result->empty()); + } + + TEST_METHOD(GetLastErrorMessage_PathNotFound_ReturnsMessage) + { + auto result = get_last_error_message(ERROR_PATH_NOT_FOUND); + Assert::IsTrue(result.has_value()); + Assert::IsFalse(result->empty()); + } + + TEST_METHOD(GetLastErrorMessage_InvalidHandle_ReturnsMessage) + { + auto result = get_last_error_message(ERROR_INVALID_HANDLE); + Assert::IsTrue(result.has_value()); + Assert::IsFalse(result->empty()); + } + + TEST_METHOD(GetLastErrorMessage_NotEnoughMemory_ReturnsMessage) + { + auto result = get_last_error_message(ERROR_NOT_ENOUGH_MEMORY); + Assert::IsTrue(result.has_value()); + Assert::IsFalse(result->empty()); + } + + TEST_METHOD(GetLastErrorMessage_InvalidParameter_ReturnsMessage) + { + auto result = get_last_error_message(ERROR_INVALID_PARAMETER); + Assert::IsTrue(result.has_value()); + Assert::IsFalse(result->empty()); + } + + // get_last_error_or_default tests + TEST_METHOD(GetLastErrorOrDefault_Success_ReturnsMessage) + { + auto result = get_last_error_or_default(ERROR_SUCCESS); + Assert::IsFalse(result.empty()); + } + + TEST_METHOD(GetLastErrorOrDefault_FileNotFound_ReturnsMessage) + { + auto result = get_last_error_or_default(ERROR_FILE_NOT_FOUND); + Assert::IsFalse(result.empty()); + } + + TEST_METHOD(GetLastErrorOrDefault_AccessDenied_ReturnsMessage) + { + auto result = get_last_error_or_default(ERROR_ACCESS_DENIED); + Assert::IsFalse(result.empty()); + } + + TEST_METHOD(GetLastErrorOrDefault_UnknownError_ReturnsEmptyOrMessage) + { + // For an unknown error code, should return empty string or a default message + auto result = get_last_error_or_default(0xFFFFFFFF); + // Either empty or has content, both are valid + Assert::IsTrue(result.empty() || !result.empty()); + } + + // Comparison tests + TEST_METHOD(BothFunctions_SameError_ProduceSameContent) + { + auto message = get_last_error_message(ERROR_FILE_NOT_FOUND); + auto defaultMessage = get_last_error_or_default(ERROR_FILE_NOT_FOUND); + + Assert::IsTrue(message.has_value()); + Assert::AreEqual(*message, defaultMessage); + } + + TEST_METHOD(BothFunctions_SuccessError_ProduceSameContent) + { + auto message = get_last_error_message(ERROR_SUCCESS); + auto defaultMessage = get_last_error_or_default(ERROR_SUCCESS); + + Assert::IsTrue(message.has_value()); + Assert::AreEqual(*message, defaultMessage); + } + + // Error code specific tests + TEST_METHOD(GetLastErrorMessage_SharingViolation_ReturnsMessage) + { + auto result = get_last_error_message(ERROR_SHARING_VIOLATION); + Assert::IsTrue(result.has_value()); + Assert::IsFalse(result->empty()); + } + + TEST_METHOD(GetLastErrorMessage_FileExists_ReturnsMessage) + { + auto result = get_last_error_message(ERROR_FILE_EXISTS); + Assert::IsTrue(result.has_value()); + Assert::IsFalse(result->empty()); + } + + TEST_METHOD(GetLastErrorMessage_DirNotEmpty_ReturnsMessage) + { + auto result = get_last_error_message(ERROR_DIR_NOT_EMPTY); + Assert::IsTrue(result.has_value()); + Assert::IsFalse(result->empty()); + } + }; +} diff --git a/src/common/UnitTests-CommonUtils/Window.Tests.cpp b/src/common/UnitTests-CommonUtils/Window.Tests.cpp new file mode 100644 index 0000000000..c149795f8b --- /dev/null +++ b/src/common/UnitTests-CommonUtils/Window.Tests.cpp @@ -0,0 +1,159 @@ +#include "pch.h" +#include "TestHelpers.h" +#include + +using namespace Microsoft::VisualStudio::CppUnitTestFramework; + +namespace UnitTestsCommonUtils +{ + TEST_CLASS(WindowTests) + { + public: + // is_system_window tests + TEST_METHOD(IsSystemWindow_DesktopWindow_ReturnsResult) + { + HWND desktop = GetDesktopWindow(); + Assert::IsNotNull(desktop); + + // Get class name + char className[256] = {}; + GetClassNameA(desktop, className, sizeof(className)); + + bool result = is_system_window(desktop, className); + // Just verify it doesn't crash and returns a boolean + Assert::IsTrue(result == true || result == false); + } + + TEST_METHOD(IsSystemWindow_NullHwnd_ReturnsFalse) + { + auto shell = GetShellWindow(); + auto desktop = GetDesktopWindow(); + bool result = is_system_window(nullptr, "ClassName"); + bool expected = (shell == nullptr) || (desktop == nullptr); + Assert::AreEqual(expected, result); + } + + TEST_METHOD(IsSystemWindow_InvalidHwnd_ReturnsFalse) + { + bool result = is_system_window(reinterpret_cast(0x12345678), "ClassName"); + Assert::IsFalse(result); + } + + TEST_METHOD(IsSystemWindow_EmptyClassName_DoesNotCrash) + { + HWND desktop = GetDesktopWindow(); + bool result = is_system_window(desktop, ""); + // Just verify it doesn't crash + Assert::IsTrue(result == true || result == false); + } + + TEST_METHOD(IsSystemWindow_NullClassName_DoesNotCrash) + { + HWND desktop = GetDesktopWindow(); + bool result = is_system_window(desktop, nullptr); + // Should handle null className gracefully + Assert::IsTrue(result == true || result == false); + } + + // GetWindowCreateParam tests + TEST_METHOD(GetWindowCreateParam_ValidLparam_ReturnsValue) + { + struct TestData + { + int value; + }; + + TestData data{ 42 }; + CREATESTRUCT cs{}; + cs.lpCreateParams = &data; + + auto result = GetWindowCreateParam(reinterpret_cast(&cs)); + Assert::IsNotNull(result); + Assert::AreEqual(42, result->value); + } + + // Window data storage tests + TEST_METHOD(WindowData_StoreAndRetrieve_Works) + { + // Create a simple message-only window for testing + WNDCLASSW wc = {}; + wc.lpfnWndProc = DefWindowProcW; + wc.hInstance = GetModuleHandleW(nullptr); + wc.lpszClassName = L"TestWindowClass_DataTest"; + RegisterClassW(&wc); + + HWND hwnd = CreateWindowExW(0, L"TestWindowClass_DataTest", L"Test", + 0, 0, 0, 0, 0, HWND_MESSAGE, nullptr, + GetModuleHandleW(nullptr), nullptr); + + if (hwnd) + { + int value = 42; + int* testValue = &value; + StoreWindowParam(hwnd, testValue); + + auto retrieved = GetWindowParam(hwnd); + Assert::AreEqual(testValue, retrieved); + + DestroyWindow(hwnd); + } + + UnregisterClassW(L"TestWindowClass_DataTest", GetModuleHandleW(nullptr)); + Assert::IsTrue(true); // Window creation might fail in test environment + } + + // run_message_loop tests + TEST_METHOD(RunMessageLoop_UntilIdle_Completes) + { + // Run message loop until idle with a timeout + // This should complete quickly since there are no messages + auto start = std::chrono::steady_clock::now(); + + run_message_loop(true, 100); + + auto elapsed = std::chrono::duration_cast( + std::chrono::steady_clock::now() - start); + + // Should complete within reasonable time + Assert::IsTrue(elapsed.count() < 500); + } + + TEST_METHOD(RunMessageLoop_WithTimeout_RespectsTimeout) + { + auto start = std::chrono::steady_clock::now(); + + run_message_loop(false, 50); + + auto elapsed = std::chrono::duration_cast( + std::chrono::steady_clock::now() - start); + + // Should take at least the timeout duration + // Allow some tolerance for timing + Assert::IsTrue(elapsed.count() >= 40 && elapsed.count() < 500); + } + + TEST_METHOD(RunMessageLoop_ZeroTimeout_CompletesImmediately) + { + auto start = std::chrono::steady_clock::now(); + + run_message_loop(false, 0); + + auto elapsed = std::chrono::duration_cast( + std::chrono::steady_clock::now() - start); + + // Should complete very quickly + Assert::IsTrue(elapsed.count() < 100); + } + + TEST_METHOD(RunMessageLoop_NoTimeout_ProcessesMessages) + { + // Post a quit message before starting the loop + PostQuitMessage(0); + + // Should process the quit message and exit + run_message_loop(false, std::nullopt); + + Assert::IsTrue(true); + } + }; +} diff --git a/src/common/UnitTests-CommonUtils/packages.config b/src/common/UnitTests-CommonUtils/packages.config new file mode 100644 index 0000000000..2e5039eb82 --- /dev/null +++ b/src/common/UnitTests-CommonUtils/packages.config @@ -0,0 +1,4 @@ + + + + diff --git a/src/common/UnitTests-CommonUtils/pch.cpp b/src/common/UnitTests-CommonUtils/pch.cpp new file mode 100644 index 0000000000..64b7eef6d6 --- /dev/null +++ b/src/common/UnitTests-CommonUtils/pch.cpp @@ -0,0 +1,5 @@ +// pch.cpp: source file corresponding to the pre-compiled header + +#include "pch.h" + +// When you are using pre-compiled headers, this source file is necessary for compilation to succeed. diff --git a/src/common/UnitTests-CommonUtils/pch.h b/src/common/UnitTests-CommonUtils/pch.h new file mode 100644 index 0000000000..bae11fc8e8 --- /dev/null +++ b/src/common/UnitTests-CommonUtils/pch.h @@ -0,0 +1,39 @@ +// pch.h: This is a precompiled header file. +// Files listed below are compiled only once, improving build performance for future builds. +// This also affects IntelliSense performance, including code completion and many code browsing features. +// However, files listed here are ALL re-compiled if any one of them is updated between builds. +// Do not add files here that you will be updating frequently as this negates the performance advantage. + +#ifndef PCH_H +#define PCH_H + +// add headers that you want to pre-compile here +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// Suppressing 26466 - Don't use static_cast downcasts - in CppUnitTest.h +#pragma warning(push) +#pragma warning(disable : 26466) +#include "CppUnitTest.h" +#pragma warning(pop) + +#endif //PCH_H diff --git a/src/common/UnitTests-CommonUtils/resource.h b/src/common/UnitTests-CommonUtils/resource.h new file mode 100644 index 0000000000..6af7276e95 --- /dev/null +++ b/src/common/UnitTests-CommonUtils/resource.h @@ -0,0 +1,13 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by UnitTests-CommonUtils.rc + +////////////////////////////// +// Non-localizable + +#define FILE_DESCRIPTION "PowerToys UnitTests-CommonUtils" +#define INTERNAL_NAME "UnitTests-CommonUtils" +#define ORIGINAL_FILENAME "UnitTests-CommonUtils.dll" + +// Non-localizable +////////////////////////////// diff --git a/src/common/interop/Constants.cpp b/src/common/interop/Constants.cpp index fef43de566..67b4da51f2 100644 --- a/src/common/interop/Constants.cpp +++ b/src/common/interop/Constants.cpp @@ -251,4 +251,40 @@ namespace winrt::PowerToys::Interop::implementation { return CommonSharedConstants::CMDPAL_SHOW_EVENT; } + hstring Constants::TogglePowerDisplayEvent() + { + return CommonSharedConstants::TOGGLE_POWER_DISPLAY_EVENT; + } + hstring Constants::TerminatePowerDisplayEvent() + { + return CommonSharedConstants::TERMINATE_POWER_DISPLAY_EVENT; + } + hstring Constants::RefreshPowerDisplayMonitorsEvent() + { + return CommonSharedConstants::REFRESH_POWER_DISPLAY_MONITORS_EVENT; + } + hstring Constants::SettingsUpdatedPowerDisplayEvent() + { + return CommonSharedConstants::SETTINGS_UPDATED_POWER_DISPLAY_EVENT; + } + hstring Constants::PowerDisplaySendSettingsTelemetryEvent() + { + return CommonSharedConstants::POWER_DISPLAY_SEND_SETTINGS_TELEMETRY_EVENT; + } + hstring Constants::HotkeyUpdatedPowerDisplayEvent() + { + return CommonSharedConstants::HOTKEY_UPDATED_POWER_DISPLAY_EVENT; + } + hstring Constants::PowerDisplayToggleMessage() + { + return CommonSharedConstants::POWER_DISPLAY_TOGGLE_MESSAGE; + } + hstring Constants::PowerDisplayApplyProfileMessage() + { + return CommonSharedConstants::POWER_DISPLAY_APPLY_PROFILE_MESSAGE; + } + hstring Constants::PowerDisplayTerminateAppMessage() + { + return CommonSharedConstants::POWER_DISPLAY_TERMINATE_APP_MESSAGE; + } } diff --git a/src/common/interop/Constants.h b/src/common/interop/Constants.h index cdd883cc41..faa2a97379 100644 --- a/src/common/interop/Constants.h +++ b/src/common/interop/Constants.h @@ -66,6 +66,15 @@ namespace winrt::PowerToys::Interop::implementation static hstring WorkspacesHotkeyEvent(); static hstring PowerToysRunnerTerminateSettingsEvent(); static hstring ShowCmdPalEvent(); + static hstring TogglePowerDisplayEvent(); + static hstring TerminatePowerDisplayEvent(); + static hstring RefreshPowerDisplayMonitorsEvent(); + static hstring SettingsUpdatedPowerDisplayEvent(); + static hstring PowerDisplaySendSettingsTelemetryEvent(); + static hstring HotkeyUpdatedPowerDisplayEvent(); + static hstring PowerDisplayToggleMessage(); + static hstring PowerDisplayApplyProfileMessage(); + static hstring PowerDisplayTerminateAppMessage(); }; } diff --git a/src/common/interop/Constants.idl b/src/common/interop/Constants.idl index abd642b197..042d790699 100644 --- a/src/common/interop/Constants.idl +++ b/src/common/interop/Constants.idl @@ -63,6 +63,15 @@ namespace PowerToys static String WorkspacesHotkeyEvent(); static String PowerToysRunnerTerminateSettingsEvent(); static String ShowCmdPalEvent(); + static String TogglePowerDisplayEvent(); + static String TerminatePowerDisplayEvent(); + static String RefreshPowerDisplayMonitorsEvent(); + static String SettingsUpdatedPowerDisplayEvent(); + static String PowerDisplaySendSettingsTelemetryEvent(); + static String HotkeyUpdatedPowerDisplayEvent(); + static String PowerDisplayToggleMessage(); + static String PowerDisplayApplyProfileMessage(); + static String PowerDisplayTerminateAppMessage(); } } } diff --git a/src/common/interop/PowerToys.Interop.vcxproj b/src/common/interop/PowerToys.Interop.vcxproj index ca29e69cce..a531a65d9f 100644 --- a/src/common/interop/PowerToys.Interop.vcxproj +++ b/src/common/interop/PowerToys.Interop.vcxproj @@ -41,7 +41,7 @@ DynamicLibrary - v143 + Unicode false diff --git a/src/common/interop/shared_constants.h b/src/common/interop/shared_constants.h index 118683f24c..079f53c85c 100644 --- a/src/common/interop/shared_constants.h +++ b/src/common/interop/shared_constants.h @@ -72,6 +72,10 @@ namespace CommonSharedConstants const wchar_t ALWAYS_ON_TOP_TERMINATE_EVENT[] = L"Local\\AlwaysOnTopTerminateEvent-cfdf1eae-791f-4953-8021-2f18f3837eae"; + const wchar_t ALWAYS_ON_TOP_INCREASE_OPACITY_EVENT[] = L"Local\\AlwaysOnTopIncreaseOpacityEvent-a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + + const wchar_t ALWAYS_ON_TOP_DECREASE_OPACITY_EVENT[] = L"Local\\AlwaysOnTopDecreaseOpacityEvent-b2c3d4e5-f6a7-8901-bcde-f12345678901"; + // Path to the event used by PowerAccent const wchar_t POWERACCENT_EXIT_EVENT[] = L"Local\\PowerToysPowerAccentExitEvent-53e93389-d19a-4fbb-9b36-1981c8965e17"; @@ -149,6 +153,23 @@ namespace CommonSharedConstants const wchar_t ZOOMIT_SNIP_EVENT[] = L"Local\\PowerToysZoomIt-SnipEvent-2fd9c211-436d-4f17-a902-2528aaae3e30"; const wchar_t ZOOMIT_RECORD_EVENT[] = L"Local\\PowerToysZoomIt-RecordEvent-74539344-eaad-4711-8e83-23946e424512"; + // Path to the events used by PowerDisplay + const wchar_t TOGGLE_POWER_DISPLAY_EVENT[] = L"Local\\PowerToysPowerDisplay-ToggleEvent-5f1a9c3e-7d2b-4e8f-9a6c-3b5d7e9f1a2c"; + const wchar_t TERMINATE_POWER_DISPLAY_EVENT[] = L"Local\\PowerToysPowerDisplay-TerminateEvent-7b9c2e1f-8a5d-4c3e-9f6b-2a1d8c5e3b7a"; + const wchar_t REFRESH_POWER_DISPLAY_MONITORS_EVENT[] = L"Local\\PowerToysPowerDisplay-RefreshMonitorsEvent-a3f5c8e7-9d1b-4e2f-8c6a-3b5d7e9f1a2c"; + const wchar_t SETTINGS_UPDATED_POWER_DISPLAY_EVENT[] = L"Local\\PowerToysPowerDisplay-SettingsUpdatedEvent-2e4d6f8a-1c3b-5e7f-9a1d-4c6e8f0b2d3e"; + const wchar_t POWER_DISPLAY_SEND_SETTINGS_TELEMETRY_EVENT[] = L"Local\\PowerToysPowerDisplay-SettingsTelemetryEvent-8c4f2a1d-5e3b-7f9c-1a6d-3b8e5f2c9a7d"; + const wchar_t HOTKEY_UPDATED_POWER_DISPLAY_EVENT[] = L"Local\\PowerToysPowerDisplay-HotkeyUpdatedEvent-9d5f3a2b-7e1c-4b8a-6f3d-2a9e5c7b1d4f"; + + // IPC Messages used in PowerDisplay (Named Pipe communication) + const wchar_t POWER_DISPLAY_TOGGLE_MESSAGE[] = L"Toggle"; + const wchar_t POWER_DISPLAY_APPLY_PROFILE_MESSAGE[] = L"ApplyProfile"; + const wchar_t POWER_DISPLAY_TERMINATE_APP_MESSAGE[] = L"TerminateApp"; + + // Path to the events used by LightSwitch to notify PowerDisplay of theme changes + const wchar_t LIGHT_SWITCH_LIGHT_THEME_EVENT[] = L"Local\\PowerToysLightSwitch-LightThemeEvent-50077121-2ffc-4841-9c86-ab1bd3f9baca"; + const wchar_t LIGHT_SWITCH_DARK_THEME_EVENT[] = L"Local\\PowerToysLightSwitch-DarkThemeEvent-b3a835c0-eaa2-49b0-b8eb-f793e3df3368"; + // used from quick access window const wchar_t CMDPAL_SHOW_EVENT[] = L"Local\\PowerToysCmdPal-ShowEvent-62336fcd-8611-4023-9b30-091a6af4cc5a"; const wchar_t CMDPAL_EXIT_EVENT[] = L"Local\\PowerToysCmdPal-ExitEvent-eb73f6be-3f22-4b36-aee3-62924ba40bfd"; diff --git a/src/common/logger/logger.vcxproj b/src/common/logger/logger.vcxproj index 3e5529a747..157b3d1abc 100644 --- a/src/common/logger/logger.vcxproj +++ b/src/common/logger/logger.vcxproj @@ -36,7 +36,7 @@ StaticLibrary - v143 + ..\..\..\$(Platform)\$(Configuration)\ diff --git a/src/common/logger/logger_settings.h b/src/common/logger/logger_settings.h index 881633e05e..6f0592ea53 100644 --- a/src/common/logger/logger_settings.h +++ b/src/common/logger/logger_settings.h @@ -83,6 +83,7 @@ struct LogSettings inline const static std::wstring workspacesSnapshotToolLogPath = L"workspaces-snapshot-tool-log.log"; inline const static std::string zoomItLoggerName = "zoom-it"; inline const static std::string lightSwitchLoggerName = "light-switch"; + inline const static std::string powerDisplayLoggerName = "powerdisplay"; inline const static int retention = 30; std::wstring logLevel; LogSettings(); diff --git a/src/common/notifications/BackgroundActivator/BackgroundActivator.vcxproj b/src/common/notifications/BackgroundActivator/BackgroundActivator.vcxproj index 077333a664..a201dc0fa5 100644 --- a/src/common/notifications/BackgroundActivator/BackgroundActivator.vcxproj +++ b/src/common/notifications/BackgroundActivator/BackgroundActivator.vcxproj @@ -18,7 +18,7 @@ StaticLibrary - v143 + Unicode false diff --git a/src/common/notifications/BackgroundActivatorDLL/BackgroundActivatorDLL.vcxproj b/src/common/notifications/BackgroundActivatorDLL/BackgroundActivatorDLL.vcxproj index 88a9a6b5f2..c0b0cae81b 100644 --- a/src/common/notifications/BackgroundActivatorDLL/BackgroundActivatorDLL.vcxproj +++ b/src/common/notifications/BackgroundActivatorDLL/BackgroundActivatorDLL.vcxproj @@ -10,7 +10,7 @@ DynamicLibrary - v143 + diff --git a/src/common/notifications/notifications.vcxproj b/src/common/notifications/notifications.vcxproj index b55d67e7b3..33ee65cdc3 100644 --- a/src/common/notifications/notifications.vcxproj +++ b/src/common/notifications/notifications.vcxproj @@ -11,7 +11,7 @@ StaticLibrary - v143 + diff --git a/src/common/updating/updating.vcxproj b/src/common/updating/updating.vcxproj index bfcf1f22b4..cf3fb9b175 100644 --- a/src/common/updating/updating.vcxproj +++ b/src/common/updating/updating.vcxproj @@ -12,7 +12,7 @@ StaticLibrary - v143 + diff --git a/src/common/utils/EventLocker.h b/src/common/utils/EventLocker.h index 01bd7b79c9..687ae7829b 100644 --- a/src/common/utils/EventLocker.h +++ b/src/common/utils/EventLocker.h @@ -34,6 +34,7 @@ public: { this->eventHandle = e.eventHandle; e.eventHandle = nullptr; + return *this; } ~EventLocker() diff --git a/src/common/utils/MsWindowsSettings.h b/src/common/utils/MsWindowsSettings.h index ceb54e41c2..22c4c78637 100644 --- a/src/common/utils/MsWindowsSettings.h +++ b/src/common/utils/MsWindowsSettings.h @@ -1,5 +1,8 @@ #pragma once +#include +#include "../logger/logger.h" + inline bool GetAnimationsEnabled() { BOOL enabled = 0; @@ -10,4 +13,4 @@ inline bool GetAnimationsEnabled() Logger::error("SystemParametersInfo SPI_GETCLIENTAREAANIMATION failed."); } return enabled; -} \ No newline at end of file +} diff --git a/src/common/utils/ProcessWaiter.h b/src/common/utils/ProcessWaiter.h index badef9ffce..2205844743 100644 --- a/src/common/utils/ProcessWaiter.h +++ b/src/common/utils/ProcessWaiter.h @@ -7,7 +7,19 @@ namespace ProcessWaiter { void OnProcessTerminate(std::wstring parent_pid, std::function callback) { - DWORD pid = std::stol(parent_pid); + DWORD pid = 0; + try + { + pid = std::stol(parent_pid); + } + catch (...) + { + if (callback) + { + callback(ERROR_INVALID_PARAMETER); + } + return; + } std::thread([=]() { HANDLE process = OpenProcess(SYNCHRONIZE, FALSE, pid); if (process != nullptr) @@ -15,17 +27,26 @@ namespace ProcessWaiter if (WaitForSingleObject(process, INFINITE) == WAIT_OBJECT_0) { CloseHandle(process); - callback(ERROR_SUCCESS); + if (callback) + { + callback(ERROR_SUCCESS); + } } else { CloseHandle(process); - callback(GetLastError()); + if (callback) + { + callback(GetLastError()); + } } } else { - callback(GetLastError()); + if (callback) + { + callback(GetLastError()); + } } }).detach(); } diff --git a/src/common/utils/com_object_factory.h b/src/common/utils/com_object_factory.h index fd2490691f..08f5336938 100644 --- a/src/common/utils/com_object_factory.h +++ b/src/common/utils/com_object_factory.h @@ -31,6 +31,10 @@ public: HRESULT __stdcall CreateInstance(IUnknown* punkOuter, const IID& riid, void** ppv) { + if (!ppv) + { + return E_POINTER; + } *ppv = nullptr; if (punkOuter) @@ -55,4 +59,4 @@ public: private: std::atomic _refCount; -}; \ No newline at end of file +}; diff --git a/src/common/utils/gpo.h b/src/common/utils/gpo.h index ab71d09d0b..0b2611b076 100644 --- a/src/common/utils/gpo.h +++ b/src/common/utils/gpo.h @@ -32,6 +32,7 @@ namespace powertoys_gpo const std::wstring POLICY_CONFIGURE_ENABLED_COLOR_PICKER = L"ConfigureEnabledUtilityColorPicker"; const std::wstring POLICY_CONFIGURE_ENABLED_CROP_AND_LOCK = L"ConfigureEnabledUtilityCropAndLock"; const std::wstring POLICY_CONFIGURE_ENABLED_LIGHT_SWITCH = L"ConfigureEnabledUtilityLightSwitch"; + const std::wstring POLICY_CONFIGURE_ENABLED_POWER_DISPLAY = L"ConfigureEnabledUtilityPowerDisplay"; const std::wstring POLICY_CONFIGURE_ENABLED_FANCYZONES = L"ConfigureEnabledUtilityFancyZones"; const std::wstring POLICY_CONFIGURE_ENABLED_FILE_LOCKSMITH = L"ConfigureEnabledUtilityFileLocksmith"; const std::wstring POLICY_CONFIGURE_ENABLED_SVG_PREVIEW = L"ConfigureEnabledUtilityFileExplorerSVGPreview"; @@ -310,6 +311,11 @@ namespace powertoys_gpo return getUtilityEnabledValue(POLICY_CONFIGURE_ENABLED_LIGHT_SWITCH); } + inline gpo_rule_configured_t getConfiguredPowerDisplayEnabledValue() + { + return getUtilityEnabledValue(POLICY_CONFIGURE_ENABLED_POWER_DISPLAY); + } + inline gpo_rule_configured_t getConfiguredFancyZonesEnabledValue() { return getUtilityEnabledValue(POLICY_CONFIGURE_ENABLED_FANCYZONES); diff --git a/src/common/utils/logger_helper.h b/src/common/utils/logger_helper.h index 2deec22155..1e7e937c5a 100644 --- a/src/common/utils/logger_helper.h +++ b/src/common/utils/logger_helper.h @@ -3,6 +3,7 @@ #include #include #include +#include "../logger/logger.h" namespace LoggerHelpers { diff --git a/src/common/utils/package.h b/src/common/utils/package.h index 58961f93ac..6db77d593f 100644 --- a/src/common/utils/package.h +++ b/src/common/utils/package.h @@ -21,9 +21,16 @@ namespace package { - using namespace winrt::Windows::Foundation; - using namespace winrt::Windows::ApplicationModel; - using namespace winrt::Windows::Management::Deployment; + using winrt::Windows::ApplicationModel::Package; + using winrt::Windows::Foundation::IAsyncOperationWithProgress; + using winrt::Windows::Foundation::AsyncStatus; + using winrt::Windows::Foundation::Uri; + using winrt::Windows::Foundation::Collections::IVector; + using winrt::Windows::Management::Deployment::AddPackageOptions; + using winrt::Windows::Management::Deployment::DeploymentOptions; + using winrt::Windows::Management::Deployment::DeploymentProgress; + using winrt::Windows::Management::Deployment::DeploymentResult; + using winrt::Windows::Management::Deployment::PackageManager; using Microsoft::WRL::ComPtr; inline BOOL IsWin11OrGreater() @@ -435,7 +442,7 @@ namespace package // Declare use of an external location DeploymentOptions options = DeploymentOptions::ForceTargetApplicationShutdown; - Collections::IVector uris = winrt::single_threaded_vector(); + IVector uris = winrt::single_threaded_vector(); if (!dependencies.empty()) { for (const auto& dependency : dependencies) diff --git a/src/common/utils/timeutil.h b/src/common/utils/timeutil.h index b82e7981bd..38858fb756 100644 --- a/src/common/utils/timeutil.h +++ b/src/common/utils/timeutil.h @@ -4,6 +4,7 @@ #include #include #include +#include #include @@ -27,6 +28,17 @@ namespace timeutil { try { + if (s.empty()) + { + return std::nullopt; + } + for (wchar_t ch : s) + { + if (!iswdigit(ch)) + { + return std::nullopt; + } + } uint64_t i = std::stoull(s); return static_cast(i); } diff --git a/src/common/version/version.vcxproj b/src/common/version/version.vcxproj index b045d8f5a5..fff8efc983 100644 --- a/src/common/version/version.vcxproj +++ b/src/common/version/version.vcxproj @@ -47,7 +47,7 @@ StaticLibrary - v143 + diff --git a/src/gpo/assets/PowerToys.admx b/src/gpo/assets/PowerToys.admx index 4b77a6783f..ddef3d95eb 100644 --- a/src/gpo/assets/PowerToys.admx +++ b/src/gpo/assets/PowerToys.admx @@ -1,11 +1,11 @@ - + - + @@ -27,6 +27,7 @@ + @@ -148,6 +149,16 @@ + + + + + + + + + + @@ -338,6 +349,16 @@ + + + + + + + + + + diff --git a/src/gpo/assets/en-US/PowerToys.adml b/src/gpo/assets/en-US/PowerToys.adml index 1bfa55866d..ccd38d9934 100644 --- a/src/gpo/assets/en-US/PowerToys.adml +++ b/src/gpo/assets/en-US/PowerToys.adml @@ -1,7 +1,7 @@ - + PowerToys PowerToys @@ -34,6 +34,7 @@ PowerToys version 0.89.0 or later PowerToys version 0.90.0 or later PowerToys version 0.96.0 or later + PowerToys version 0.97.0 or later From PowerToys version 0.64.0 until PowerToys version 0.87.1 This policy configures the enabled state for all PowerToys utilities. @@ -247,6 +248,7 @@ If you don't configure this policy, the user will be able to control the setting CmdPal: Configure enabled state Crop And Lock: Configure enabled state Light Switch: Configure enabled state + PowerDisplay: Configure enabled state Environment Variables: Configure enabled state FancyZones: Configure enabled state File Locksmith: Configure enabled state @@ -266,6 +268,7 @@ If you don't configure this policy, the user will be able to control the setting Keyboard Manager: Configure enabled state Find My Mouse: Configure enabled state Mouse Highlighter: Configure enabled state + CursorWrap: Configure enabled state Mouse Jump: Configure enabled state Mouse Pointer Crosshairs: Configure enabled state Mouse Without Borders: Configure enabled state diff --git a/src/logging/logging.vcxproj b/src/logging/logging.vcxproj index ee1c6a7078..b1a584ec94 100644 --- a/src/logging/logging.vcxproj +++ b/src/logging/logging.vcxproj @@ -36,7 +36,7 @@ StaticLibrary MultiByte - v143 + ..\..\$(Platform)\$(Configuration)\ diff --git a/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/AdvancedPasteModuleInterface.vcxproj b/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/AdvancedPasteModuleInterface.vcxproj index 2cf2920673..7ccd5e0cda 100644 --- a/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/AdvancedPasteModuleInterface.vcxproj +++ b/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/AdvancedPasteModuleInterface.vcxproj @@ -15,7 +15,7 @@ DynamicLibrary - v143 + diff --git a/src/modules/CropAndLock/CropAndLock/CropAndLock.vcxproj b/src/modules/CropAndLock/CropAndLock/CropAndLock.vcxproj index dfe9f11b2e..6d33ed8c4e 100644 --- a/src/modules/CropAndLock/CropAndLock/CropAndLock.vcxproj +++ b/src/modules/CropAndLock/CropAndLock/CropAndLock.vcxproj @@ -34,10 +34,6 @@ Application - v143 - v142 - v143 - v143 Unicode Spectre diff --git a/src/modules/CropAndLock/CropAndLockModuleInterface/CropAndLockModuleInterface.vcxproj b/src/modules/CropAndLock/CropAndLockModuleInterface/CropAndLockModuleInterface.vcxproj index c7fccf462a..af72e21d21 100644 --- a/src/modules/CropAndLock/CropAndLockModuleInterface/CropAndLockModuleInterface.vcxproj +++ b/src/modules/CropAndLock/CropAndLockModuleInterface/CropAndLockModuleInterface.vcxproj @@ -12,13 +12,13 @@ DynamicLibrary true - v143 + Unicode DynamicLibrary false - v143 + true Unicode diff --git a/src/modules/EnvironmentVariables/EnvironmentVariablesModuleInterface/EnvironmentVariablesModuleInterface.vcxproj b/src/modules/EnvironmentVariables/EnvironmentVariablesModuleInterface/EnvironmentVariablesModuleInterface.vcxproj index 068a0ad590..ff4c557a64 100644 --- a/src/modules/EnvironmentVariables/EnvironmentVariablesModuleInterface/EnvironmentVariablesModuleInterface.vcxproj +++ b/src/modules/EnvironmentVariables/EnvironmentVariablesModuleInterface/EnvironmentVariablesModuleInterface.vcxproj @@ -15,13 +15,13 @@ DynamicLibrary true - v143 + Unicode DynamicLibrary false - v143 + true Unicode diff --git a/src/modules/FileLocksmith/FileLocksmithCLI/FileLocksmithCLI.vcxproj b/src/modules/FileLocksmith/FileLocksmithCLI/FileLocksmithCLI.vcxproj index ca85b58d28..a8f2d46b97 100644 --- a/src/modules/FileLocksmith/FileLocksmithCLI/FileLocksmithCLI.vcxproj +++ b/src/modules/FileLocksmith/FileLocksmithCLI/FileLocksmithCLI.vcxproj @@ -14,13 +14,11 @@ Application true - v143 Unicode Application false - v143 Unicode diff --git a/src/modules/FileLocksmith/FileLocksmithCLI/tests/FileLocksmithCLIUnitTests.vcxproj b/src/modules/FileLocksmith/FileLocksmithCLI/tests/FileLocksmithCLIUnitTests.vcxproj index 4b423c0183..12b0a89c7b 100644 --- a/src/modules/FileLocksmith/FileLocksmithCLI/tests/FileLocksmithCLIUnitTests.vcxproj +++ b/src/modules/FileLocksmith/FileLocksmithCLI/tests/FileLocksmithCLIUnitTests.vcxproj @@ -11,7 +11,6 @@ - v143 DynamicLibrary Unicode diff --git a/src/modules/FileLocksmith/FileLocksmithContextMenu/FileLocksmithContextMenu.vcxproj b/src/modules/FileLocksmith/FileLocksmithContextMenu/FileLocksmithContextMenu.vcxproj index 10478cd30c..a1617bfc06 100644 --- a/src/modules/FileLocksmith/FileLocksmithContextMenu/FileLocksmithContextMenu.vcxproj +++ b/src/modules/FileLocksmith/FileLocksmithContextMenu/FileLocksmithContextMenu.vcxproj @@ -20,13 +20,13 @@ DynamicLibrary true - v143 + Unicode DynamicLibrary false - v143 + true Unicode diff --git a/src/modules/FileLocksmith/FileLocksmithExt/FileLocksmithExt.vcxproj b/src/modules/FileLocksmith/FileLocksmithExt/FileLocksmithExt.vcxproj index c67119808f..8bca058c43 100644 --- a/src/modules/FileLocksmith/FileLocksmithExt/FileLocksmithExt.vcxproj +++ b/src/modules/FileLocksmith/FileLocksmithExt/FileLocksmithExt.vcxproj @@ -16,13 +16,13 @@ DynamicLibrary true - v143 + Unicode DynamicLibrary false - v143 + true Unicode diff --git a/src/modules/FileLocksmith/FileLocksmithLib/FileLocksmithLib.vcxproj b/src/modules/FileLocksmith/FileLocksmithLib/FileLocksmithLib.vcxproj index fdfb0c666f..e64b569387 100644 --- a/src/modules/FileLocksmith/FileLocksmithLib/FileLocksmithLib.vcxproj +++ b/src/modules/FileLocksmith/FileLocksmithLib/FileLocksmithLib.vcxproj @@ -12,13 +12,13 @@ StaticLibrary true - v143 + Unicode StaticLibrary false - v143 + Unicode diff --git a/src/modules/FileLocksmith/FileLocksmithLibInterop/FileLocksmithLibInterop.vcxproj b/src/modules/FileLocksmith/FileLocksmithLibInterop/FileLocksmithLibInterop.vcxproj index c4489cdad8..fd995a2d97 100644 --- a/src/modules/FileLocksmith/FileLocksmithLibInterop/FileLocksmithLibInterop.vcxproj +++ b/src/modules/FileLocksmith/FileLocksmithLibInterop/FileLocksmithLibInterop.vcxproj @@ -32,7 +32,7 @@ DynamicLibrary - v143 + Unicode false diff --git a/src/modules/Hosts/HostsModuleInterface/HostsModuleInterface.vcxproj b/src/modules/Hosts/HostsModuleInterface/HostsModuleInterface.vcxproj index f74481f2e0..40601496d3 100644 --- a/src/modules/Hosts/HostsModuleInterface/HostsModuleInterface.vcxproj +++ b/src/modules/Hosts/HostsModuleInterface/HostsModuleInterface.vcxproj @@ -15,13 +15,13 @@ DynamicLibrary true - v143 + Unicode DynamicLibrary false - v143 + true Unicode diff --git a/src/modules/LightSwitch/LightSwitchLib/LightSwitchLib.vcxproj b/src/modules/LightSwitch/LightSwitchLib/LightSwitchLib.vcxproj index dbc97ab86e..e6030450f7 100644 --- a/src/modules/LightSwitch/LightSwitchLib/LightSwitchLib.vcxproj +++ b/src/modules/LightSwitch/LightSwitchLib/LightSwitchLib.vcxproj @@ -31,26 +31,22 @@ StaticLibrary true - v143 Unicode StaticLibrary false - v143 true Unicode StaticLibrary true - v143 Unicode StaticLibrary false - v143 true Unicode diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchService.cpp b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.cpp index 8919f4274b..5b4fa8297b 100644 --- a/src/modules/LightSwitch/LightSwitchService/LightSwitchService.cpp +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.cpp @@ -250,7 +250,6 @@ DWORD WINAPI ServiceWorkerThread(LPVOID lpParam) Logger::info(L"[LightSwitchService] Initialized at {:02d}:{:02d}.", st.wHour, st.wMinute); stateManager.SyncInitialThemeState(); - stateManager.OnTick(nowMinutes); // ──────────────────────────────────────────────────────────────── // Worker Loop @@ -281,7 +280,7 @@ DWORD WINAPI ServiceWorkerThread(LPVOID lpParam) GetLocalTime(&st); nowMinutes = st.wHour * 60 + st.wMinute; DetectAndHandleExternalThemeChange(stateManager); - stateManager.OnTick(nowMinutes); + stateManager.OnTick(); continue; } diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj index 297731c2ae..f10364e4e3 100644 --- a/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj @@ -31,13 +31,13 @@ Application true - v143 + Unicode Application false - v143 + true Unicode diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchSettings.cpp b/src/modules/LightSwitch/LightSwitchService/LightSwitchSettings.cpp index 488142b95b..15e9f7c915 100644 --- a/src/modules/LightSwitch/LightSwitchService/LightSwitchSettings.cpp +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchSettings.cpp @@ -248,6 +248,46 @@ void LightSwitchSettings::LoadSettings() } } + // EnableDarkModeProfile + if (const auto jsonVal = values.get_bool_value(L"enableDarkModeProfile")) + { + auto val = *jsonVal; + if (m_settings.enableDarkModeProfile != val) + { + m_settings.enableDarkModeProfile = val; + } + } + + // EnableLightModeProfile + if (const auto jsonVal = values.get_bool_value(L"enableLightModeProfile")) + { + auto val = *jsonVal; + if (m_settings.enableLightModeProfile != val) + { + m_settings.enableLightModeProfile = val; + } + } + + // DarkModeProfile + if (const auto jsonVal = values.get_string_value(L"darkModeProfile")) + { + auto val = *jsonVal; + if (m_settings.darkModeProfile != val) + { + m_settings.darkModeProfile = val; + } + } + + // LightModeProfile + if (const auto jsonVal = values.get_string_value(L"lightModeProfile")) + { + auto val = *jsonVal; + if (m_settings.lightModeProfile != val) + { + m_settings.lightModeProfile = val; + } + } + // For ChangeSystem/ChangeApps changes, log telemetry if (themeTargetChanged) { diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchSettings.h b/src/modules/LightSwitch/LightSwitchService/LightSwitchSettings.h index 1d1c7953fe..4fd9777c5e 100644 --- a/src/modules/LightSwitch/LightSwitchService/LightSwitchSettings.h +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchSettings.h @@ -67,6 +67,11 @@ struct LightSwitchConfig bool changeSystem = false; bool changeApps = false; + + bool enableDarkModeProfile = false; + bool enableLightModeProfile = false; + std::wstring darkModeProfile = L""; + std::wstring lightModeProfile = L""; }; class LightSwitchSettings diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.cpp b/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.cpp index f562d38c41..28bcca6512 100644 --- a/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.cpp +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.cpp @@ -4,6 +4,7 @@ #include #include "ThemeScheduler.h" #include +#include void ApplyTheme(bool shouldBeLight); @@ -28,7 +29,7 @@ void LightSwitchStateManager::OnSettingsChanged() } // Called once per minute -void LightSwitchStateManager::OnTick(int currentMinutes) +void LightSwitchStateManager::OnTick() { std::lock_guard lock(_stateMutex); if (_state.lastAppliedMode != ScheduleMode::FollowNightLight) @@ -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 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(); @@ -109,10 +114,14 @@ void LightSwitchStateManager::SyncInitialThemeState() std::lock_guard lock(_stateMutex); _state.isSystemLightActive = GetCurrentSystemTheme(); _state.isAppsLightActive = GetCurrentAppsTheme(); + _state.isNightLightActive = IsNightLightEnabled(); Logger::debug(L"[LightSwitchStateManager] Synced initial state to current system theme ({})", _state.isSystemLightActive ? L"light" : L"dark"); Logger::debug(L"[LightSwitchStateManager] Synced initial state to current apps theme ({})", _state.isAppsLightActive ? L"light" : L"dark"); + + // This will ensure that the theme is applied according to current settings at startup + EvaluateAndApplyIfNeeded(); } static std::pair update_sun_times(auto& settings) @@ -264,7 +273,61 @@ void LightSwitchStateManager::EvaluateAndApplyIfNeeded() _state.isSystemLightActive = GetCurrentSystemTheme(); _state.isAppsLightActive = GetCurrentAppsTheme(); + + // Notify PowerDisplay to apply display profile if configured + NotifyPowerDisplay(shouldBeLight); } _state.lastTickMinutes = now; } + +// Notify PowerDisplay module about theme change to apply display profiles +void LightSwitchStateManager::NotifyPowerDisplay(bool isLight) +{ + const auto& settings = LightSwitchSettings::settings(); + + // Check if any profile is enabled and configured + bool shouldNotify = false; + + if (isLight && settings.enableLightModeProfile && !settings.lightModeProfile.empty()) + { + shouldNotify = true; + } + else if (!isLight && settings.enableDarkModeProfile && !settings.darkModeProfile.empty()) + { + shouldNotify = true; + } + + if (!shouldNotify) + { + return; + } + + try + { + // Signal PowerDisplay with the specific theme event + // Using separate events for light/dark eliminates race conditions where PowerDisplay + // might read the registry before LightSwitch has finished updating it + const wchar_t* eventName = isLight + ? CommonSharedConstants::LIGHT_SWITCH_LIGHT_THEME_EVENT + : CommonSharedConstants::LIGHT_SWITCH_DARK_THEME_EVENT; + + Logger::info(L"[LightSwitchStateManager] Notifying PowerDisplay about theme change (isLight: {})", isLight); + + HANDLE hThemeEvent = CreateEventW(nullptr, FALSE, FALSE, eventName); + if (hThemeEvent) + { + SetEvent(hThemeEvent); + CloseHandle(hThemeEvent); + Logger::info(L"[LightSwitchStateManager] Theme event signaled to PowerDisplay: {}", eventName); + } + else + { + Logger::warn(L"[LightSwitchStateManager] Failed to create theme event (error: {})", GetLastError()); + } + } + catch (...) + { + Logger::error(L"[LightSwitchStateManager] Failed to notify PowerDisplay"); + } +} diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.h b/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.h index c4f39a2e9a..b6c001fc64 100644 --- a/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.h +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.h @@ -28,7 +28,7 @@ public: void OnSettingsChanged(); // Called every minute (from service worker tick). - void OnTick(int currentMinutes); + void OnTick(); // Called when manual override is toggled (via shortcut or system change). void OnManualOverride(); @@ -48,4 +48,7 @@ private: void EvaluateAndApplyIfNeeded(); bool CoordinatesAreValid(const std::wstring& lat, const std::wstring& lon); + + // Notify PowerDisplay module about theme change to apply display profiles + void NotifyPowerDisplay(bool isLight); }; diff --git a/src/modules/MeasureTool/MeasureToolCore/PowerToys.MeasureToolCore.vcxproj b/src/modules/MeasureTool/MeasureToolCore/PowerToys.MeasureToolCore.vcxproj index c71c81acec..5a589c673d 100644 --- a/src/modules/MeasureTool/MeasureToolCore/PowerToys.MeasureToolCore.vcxproj +++ b/src/modules/MeasureTool/MeasureToolCore/PowerToys.MeasureToolCore.vcxproj @@ -30,7 +30,7 @@ Windows Store 10.0 true - true + false true diff --git a/src/modules/MeasureTool/MeasureToolModuleInterface/MeasureToolModuleInterface.vcxproj b/src/modules/MeasureTool/MeasureToolModuleInterface/MeasureToolModuleInterface.vcxproj index 557564781f..561bf95b15 100644 --- a/src/modules/MeasureTool/MeasureToolModuleInterface/MeasureToolModuleInterface.vcxproj +++ b/src/modules/MeasureTool/MeasureToolModuleInterface/MeasureToolModuleInterface.vcxproj @@ -12,13 +12,13 @@ DynamicLibrary true - v143 + Unicode DynamicLibrary false - v143 + true Unicode diff --git a/src/modules/MouseUtils/CursorWrap/CursorWrap.vcxproj b/src/modules/MouseUtils/CursorWrap/CursorWrap.vcxproj index 59e2095ca7..d4eede3cb0 100644 --- a/src/modules/MouseUtils/CursorWrap/CursorWrap.vcxproj +++ b/src/modules/MouseUtils/CursorWrap/CursorWrap.vcxproj @@ -13,13 +13,13 @@ DynamicLibrary true - v143 + Unicode DynamicLibrary false - v143 + true Unicode @@ -84,14 +84,17 @@ + + + - + Create diff --git a/src/modules/MouseUtils/CursorWrap/CursorWrapCore.cpp b/src/modules/MouseUtils/CursorWrap/CursorWrapCore.cpp new file mode 100644 index 0000000000..c1d4a9b36b --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/CursorWrapCore.cpp @@ -0,0 +1,282 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +#include "pch.h" +#include "CursorWrapCore.h" +#include "../../../common/logger/logger.h" +#include +#include +#include + +CursorWrapCore::CursorWrapCore() +{ +} + +#ifdef _DEBUG +std::wstring CursorWrapCore::GenerateTopologyJSON() const +{ + std::wostringstream json; + + // Get current time + auto now = std::time(nullptr); + std::tm tm{}; + localtime_s(&tm, &now); + + wchar_t computerName[MAX_COMPUTERNAME_LENGTH + 1] = {0}; + DWORD size = MAX_COMPUTERNAME_LENGTH + 1; + GetComputerNameW(computerName, &size); + + wchar_t userName[256] = {0}; + size = 256; + GetUserNameW(userName, &size); + + json << L"{\n"; + json << L" \"captured_at\": \"" << std::put_time(&tm, L"%Y-%m-%dT%H:%M:%S%z") << L"\",\n"; + json << L" \"computer_name\": \"" << computerName << L"\",\n"; + json << L" \"user_name\": \"" << userName << L"\",\n"; + json << L" \"monitor_count\": " << m_monitors.size() << L",\n"; + json << L" \"monitors\": [\n"; + + for (size_t i = 0; i < m_monitors.size(); ++i) + { + const auto& monitor = m_monitors[i]; + + // Get DPI for this monitor + UINT dpiX = 96, dpiY = 96; + POINT center = { + (monitor.rect.left + monitor.rect.right) / 2, + (monitor.rect.top + monitor.rect.bottom) / 2 + }; + HMONITOR hMon = MonitorFromPoint(center, MONITOR_DEFAULTTONEAREST); + if (hMon) + { + // Try GetDpiForMonitor (requires linking Shcore.lib) + using GetDpiForMonitorFunc = HRESULT (WINAPI *)(HMONITOR, int, UINT*, UINT*); + HMODULE shcore = LoadLibraryW(L"Shcore.dll"); + if (shcore) + { + auto getDpi = reinterpret_cast(GetProcAddress(shcore, "GetDpiForMonitor")); + if (getDpi) + { + getDpi(hMon, 0, &dpiX, &dpiY); // MDT_EFFECTIVE_DPI = 0 + } + FreeLibrary(shcore); + } + } + + int scalingPercent = static_cast((dpiX / 96.0) * 100); + + json << L" {\n"; + json << L" \"left\": " << monitor.rect.left << L",\n"; + json << L" \"top\": " << monitor.rect.top << L",\n"; + json << L" \"right\": " << monitor.rect.right << L",\n"; + json << L" \"bottom\": " << monitor.rect.bottom << L",\n"; + json << L" \"width\": " << (monitor.rect.right - monitor.rect.left) << L",\n"; + json << L" \"height\": " << (monitor.rect.bottom - monitor.rect.top) << L",\n"; + json << L" \"dpi\": " << dpiX << L",\n"; + json << L" \"scaling_percent\": " << scalingPercent << L",\n"; + json << L" \"primary\": " << (monitor.isPrimary ? L"true" : L"false") << L",\n"; + json << L" \"monitor_id\": " << monitor.monitorId << L"\n"; + json << L" }"; + if (i < m_monitors.size() - 1) + { + json << L","; + } + json << L"\n"; + } + + json << L" ]\n"; + json << L"}"; + + return json.str(); +} +#endif + +void CursorWrapCore::UpdateMonitorInfo() +{ + size_t previousMonitorCount = m_monitors.size(); + Logger::info(L"======= UPDATE MONITOR INFO START ======="); + Logger::info(L"Previous monitor count: {}", previousMonitorCount); + + m_monitors.clear(); + + EnumDisplayMonitors(nullptr, nullptr, [](HMONITOR hMonitor, HDC, LPRECT, LPARAM lParam) -> BOOL { + auto* self = reinterpret_cast(lParam); + + MONITORINFO mi{}; + mi.cbSize = sizeof(MONITORINFO); + if (GetMonitorInfo(hMonitor, &mi)) + { + MonitorInfo info{}; + info.hMonitor = hMonitor; // Store handle for direct comparison later + info.rect = mi.rcMonitor; + info.isPrimary = (mi.dwFlags & MONITORINFOF_PRIMARY) != 0; + info.monitorId = static_cast(self->m_monitors.size()); + self->m_monitors.push_back(info); + + Logger::info(L"Enumerated monitor {}: hMonitor={}, rect=({},{},{},{}), primary={}", + info.monitorId, reinterpret_cast(hMonitor), + mi.rcMonitor.left, mi.rcMonitor.top, mi.rcMonitor.right, mi.rcMonitor.bottom, + info.isPrimary ? L"yes" : L"no"); + } + + return TRUE; + }, reinterpret_cast(this)); + + if (previousMonitorCount != m_monitors.size()) + { + Logger::info(L"*** MONITOR CONFIGURATION CHANGED: {} -> {} monitors ***", + previousMonitorCount, m_monitors.size()); + } + + m_topology.Initialize(m_monitors); + + // Log monitor configuration summary + Logger::info(L"Monitor configuration updated: {} monitor(s)", m_monitors.size()); + for (size_t i = 0; i < m_monitors.size(); ++i) + { + const auto& m = m_monitors[i]; + int width = m.rect.right - m.rect.left; + int height = m.rect.bottom - m.rect.top; + Logger::info(L" Monitor {}: {}x{} at ({}, {}){}", + i, width, height, m.rect.left, m.rect.top, + m.isPrimary ? L" [PRIMARY]" : L""); + } + Logger::info(L" Detected {} outer edges for cursor wrapping", m_topology.GetOuterEdges().size()); + + // Detect and log monitor gaps + auto gaps = m_topology.DetectMonitorGaps(); + if (!gaps.empty()) + { + Logger::warn(L"Monitor configuration has coordinate gaps that may prevent wrapping:"); + for (const auto& gap : gaps) + { + Logger::warn(L" Gap between Monitor {} and Monitor {}: {}px horizontal gap, {}px vertical overlap", + gap.monitor1Index, gap.monitor2Index, gap.horizontalGap, gap.verticalOverlap); + } + Logger::warn(L" If monitors appear snapped in Display Settings but show gaps here:"); + Logger::warn(L" 1. Try dragging monitors apart and snapping them back together"); + Logger::warn(L" 2. Update your GPU drivers"); + } + + Logger::info(L"======= UPDATE MONITOR INFO END ======="); +} + +POINT CursorWrapCore::HandleMouseMove(const POINT& currentPos, bool disableWrapDuringDrag, int wrapMode, bool disableOnSingleMonitor) +{ + // Check if wrapping should be disabled on single monitor + if (disableOnSingleMonitor && m_monitors.size() <= 1) + { +#ifdef _DEBUG + static bool loggedOnce = false; + if (!loggedOnce) + { + OutputDebugStringW(L"[CursorWrap] Single monitor detected - cursor wrapping disabled\n"); + loggedOnce = true; + } +#endif + return currentPos; + } + + // Check if wrapping should be disabled during drag + if (disableWrapDuringDrag && (GetAsyncKeyState(VK_LBUTTON) & 0x8000)) + { +#ifdef _DEBUG + OutputDebugStringW(L"[CursorWrap] [DRAG] Left mouse button down - skipping wrap\n"); +#endif + return currentPos; + } + + // Convert int wrapMode to WrapMode enum + WrapMode mode = static_cast(wrapMode); + +#ifdef _DEBUG + { + std::wostringstream oss; + oss << L"[CursorWrap] [MOVE] Cursor at (" << currentPos.x << L", " << currentPos.y << L")"; + + // Get current monitor and identify which one + HMONITOR currentMonitor = MonitorFromPoint(currentPos, MONITOR_DEFAULTTONEAREST); + RECT monitorRect; + if (m_topology.GetMonitorRect(currentMonitor, monitorRect)) + { + // Find monitor ID + int monitorId = -1; + for (const auto& monitor : m_monitors) + { + if (monitor.rect.left == monitorRect.left && + monitor.rect.top == monitorRect.top && + monitor.rect.right == monitorRect.right && + monitor.rect.bottom == monitorRect.bottom) + { + monitorId = monitor.monitorId; + break; + } + } + oss << L" on Monitor " << monitorId << L" [" << monitorRect.left << L".." << monitorRect.right + << L", " << monitorRect.top << L".." << monitorRect.bottom << L"]"; + } + else + { + oss << L" (beyond monitor bounds)"; + } + oss << L"\n"; + OutputDebugStringW(oss.str().c_str()); + } +#endif + + // Get current monitor + HMONITOR currentMonitor = MonitorFromPoint(currentPos, MONITOR_DEFAULTTONEAREST); + + // Check if cursor is on an outer edge (filtered by wrap mode) + EdgeType edgeType; + if (!m_topology.IsOnOuterEdge(currentMonitor, currentPos, edgeType, mode)) + { +#ifdef _DEBUG + static bool lastWasNotOuter = false; + if (!lastWasNotOuter) + { + OutputDebugStringW(L"[CursorWrap] [MOVE] Not on outer edge - no wrapping\n"); + lastWasNotOuter = true; + } +#endif + return currentPos; // Not on an outer edge + } + +#ifdef _DEBUG + { + const wchar_t* edgeStr = L"Unknown"; + switch (edgeType) + { + case EdgeType::Left: edgeStr = L"Left"; break; + case EdgeType::Right: edgeStr = L"Right"; break; + case EdgeType::Top: edgeStr = L"Top"; break; + case EdgeType::Bottom: edgeStr = L"Bottom"; break; + } + std::wostringstream oss; + oss << L"[CursorWrap] [EDGE] Detected outer " << edgeStr << L" edge at (" << currentPos.x << L", " << currentPos.y << L")\n"; + OutputDebugStringW(oss.str().c_str()); + } +#endif + + // Calculate wrap destination + POINT newPos = m_topology.GetWrapDestination(currentMonitor, currentPos, edgeType); + +#ifdef _DEBUG + if (newPos.x != currentPos.x || newPos.y != currentPos.y) + { + std::wostringstream oss; + oss << L"[CursorWrap] [WRAP] Position change: (" << currentPos.x << L", " << currentPos.y + << L") -> (" << newPos.x << L", " << newPos.y << L")\n"; + oss << L"[CursorWrap] [WRAP] Delta: (" << (newPos.x - currentPos.x) << L", " << (newPos.y - currentPos.y) << L")\n"; + OutputDebugStringW(oss.str().c_str()); + } + else + { + OutputDebugStringW(L"[CursorWrap] [WRAP] No position change (same-monitor wrap?)\n"); + } +#endif + + return newPos; +} diff --git a/src/modules/MouseUtils/CursorWrap/CursorWrapCore.h b/src/modules/MouseUtils/CursorWrap/CursorWrapCore.h new file mode 100644 index 0000000000..d8472efd08 --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/CursorWrapCore.h @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +#pragma once +#include +#include +#include +#include "MonitorTopology.h" + +// Core cursor wrapping engine +class CursorWrapCore +{ +public: + CursorWrapCore(); + + void UpdateMonitorInfo(); + + // Handle mouse move with wrap mode filtering + // wrapMode: 0=Both, 1=VerticalOnly, 2=HorizontalOnly + // disableOnSingleMonitor: if true, cursor wrapping is disabled when only one monitor is connected + POINT HandleMouseMove(const POINT& currentPos, bool disableWrapDuringDrag, int wrapMode, bool disableOnSingleMonitor); + + const std::vector& GetMonitors() const { return m_monitors; } + size_t GetMonitorCount() const { return m_monitors.size(); } + const MonitorTopology& GetTopology() const { return m_topology; } + +private: +#ifdef _DEBUG + std::wstring GenerateTopologyJSON() const; +#endif + + std::vector m_monitors; + MonitorTopology m_topology; +}; diff --git a/src/modules/MouseUtils/CursorWrap/MonitorTopology.cpp b/src/modules/MouseUtils/CursorWrap/MonitorTopology.cpp new file mode 100644 index 0000000000..8e613996c6 --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/MonitorTopology.cpp @@ -0,0 +1,546 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +#include "pch.h" +#include "MonitorTopology.h" +#include "../../../common/logger/logger.h" +#include +#include + +void MonitorTopology::Initialize(const std::vector& monitors) +{ + Logger::info(L"======= TOPOLOGY INITIALIZATION START ======="); + Logger::info(L"Initializing edge-based topology for {} monitors", monitors.size()); + + m_monitors = monitors; + m_outerEdges.clear(); + m_edgeMap.clear(); + + if (monitors.empty()) + { + Logger::warn(L"No monitors provided to Initialize"); + return; + } + + // Log monitor details + for (size_t i = 0; i < monitors.size(); ++i) + { + const auto& m = monitors[i]; + Logger::info(L"Monitor {}: hMonitor={}, rect=({},{},{},{}), primary={}", + i, reinterpret_cast(m.hMonitor), + m.rect.left, m.rect.top, m.rect.right, m.rect.bottom, + m.isPrimary ? L"yes" : L"no"); + } + + BuildEdgeMap(); + IdentifyOuterEdges(); + + Logger::info(L"Found {} outer edges", m_outerEdges.size()); + for (const auto& edge : m_outerEdges) + { + const wchar_t* typeStr = L"Unknown"; + switch (edge.type) + { + case EdgeType::Left: typeStr = L"Left"; break; + case EdgeType::Right: typeStr = L"Right"; break; + case EdgeType::Top: typeStr = L"Top"; break; + case EdgeType::Bottom: typeStr = L"Bottom"; break; + } + Logger::info(L"Outer edge: Monitor {} {} at position {}, range [{}, {}]", + edge.monitorIndex, typeStr, edge.position, edge.start, edge.end); + } + Logger::info(L"======= TOPOLOGY INITIALIZATION COMPLETE ======="); +} + +void MonitorTopology::BuildEdgeMap() +{ + // Create edges for each monitor using monitor index (not HMONITOR) + // This is important because HMONITOR handles can change when monitors are + // added/removed dynamically, but indices remain stable within a single + // topology configuration + for (size_t idx = 0; idx < m_monitors.size(); ++idx) + { + const auto& monitor = m_monitors[idx]; + int monitorIndex = static_cast(idx); + + // Left edge + MonitorEdge leftEdge; + leftEdge.monitorIndex = monitorIndex; + leftEdge.type = EdgeType::Left; + leftEdge.position = monitor.rect.left; + leftEdge.start = monitor.rect.top; + leftEdge.end = monitor.rect.bottom; + leftEdge.isOuter = true; // Will be updated in IdentifyOuterEdges + m_edgeMap[{monitorIndex, EdgeType::Left}] = leftEdge; + + // Right edge + MonitorEdge rightEdge; + rightEdge.monitorIndex = monitorIndex; + rightEdge.type = EdgeType::Right; + rightEdge.position = monitor.rect.right - 1; + rightEdge.start = monitor.rect.top; + rightEdge.end = monitor.rect.bottom; + rightEdge.isOuter = true; + m_edgeMap[{monitorIndex, EdgeType::Right}] = rightEdge; + + // Top edge + MonitorEdge topEdge; + topEdge.monitorIndex = monitorIndex; + topEdge.type = EdgeType::Top; + topEdge.position = monitor.rect.top; + topEdge.start = monitor.rect.left; + topEdge.end = monitor.rect.right; + topEdge.isOuter = true; + m_edgeMap[{monitorIndex, EdgeType::Top}] = topEdge; + + // Bottom edge + MonitorEdge bottomEdge; + bottomEdge.monitorIndex = monitorIndex; + bottomEdge.type = EdgeType::Bottom; + bottomEdge.position = monitor.rect.bottom - 1; + bottomEdge.start = monitor.rect.left; + bottomEdge.end = monitor.rect.right; + bottomEdge.isOuter = true; + m_edgeMap[{monitorIndex, EdgeType::Bottom}] = bottomEdge; + } +} + +void MonitorTopology::IdentifyOuterEdges() +{ + const int tolerance = 50; + + // Check each edge against all other edges to find adjacent ones + for (auto& [key1, edge1] : m_edgeMap) + { + for (const auto& [key2, edge2] : m_edgeMap) + { + if (edge1.monitorIndex == edge2.monitorIndex) + { + continue; // Same monitor + } + + // Check if edges are adjacent + if (EdgesAreAdjacent(edge1, edge2, tolerance)) + { + edge1.isOuter = false; + break; // This edge has an adjacent monitor + } + } + + if (edge1.isOuter) + { + m_outerEdges.push_back(edge1); + } + } +} + +bool MonitorTopology::EdgesAreAdjacent(const MonitorEdge& edge1, const MonitorEdge& edge2, int tolerance) const +{ + // Edges must be opposite types to be adjacent + bool oppositeTypes = false; + + if ((edge1.type == EdgeType::Left && edge2.type == EdgeType::Right) || + (edge1.type == EdgeType::Right && edge2.type == EdgeType::Left) || + (edge1.type == EdgeType::Top && edge2.type == EdgeType::Bottom) || + (edge1.type == EdgeType::Bottom && edge2.type == EdgeType::Top)) + { + oppositeTypes = true; + } + + if (!oppositeTypes) + { + return false; + } + + // Check if positions are within tolerance + if (abs(edge1.position - edge2.position) > tolerance) + { + return false; + } + + // Check if perpendicular ranges overlap + int overlapStart = max(edge1.start, edge2.start); + int overlapEnd = min(edge1.end, edge2.end); + + return overlapEnd > overlapStart + tolerance; +} + +bool MonitorTopology::IsOnOuterEdge(HMONITOR monitor, const POINT& cursorPos, EdgeType& outEdgeType, WrapMode wrapMode) const +{ + RECT monitorRect; + if (!GetMonitorRect(monitor, monitorRect)) + { + Logger::warn(L"IsOnOuterEdge: GetMonitorRect failed for monitor handle {}", reinterpret_cast(monitor)); + return false; + } + + // Get monitor index for edge map lookup + int monitorIndex = GetMonitorIndex(monitor); + if (monitorIndex < 0) + { + Logger::warn(L"IsOnOuterEdge: Monitor index not found for handle {} at cursor ({}, {})", + reinterpret_cast(monitor), cursorPos.x, cursorPos.y); + return false; // Monitor not found in our list + } + + // Check each edge type + const int edgeThreshold = 1; + + // At corners, multiple edges may match - collect all candidates and try each + // to find one with a valid wrap destination + std::vector candidateEdges; + + // Left edge - only if mode allows horizontal wrapping + if ((wrapMode == WrapMode::Both || wrapMode == WrapMode::HorizontalOnly) && + cursorPos.x <= monitorRect.left + edgeThreshold) + { + auto it = m_edgeMap.find({monitorIndex, EdgeType::Left}); + if (it != m_edgeMap.end() && it->second.isOuter) + { + candidateEdges.push_back(EdgeType::Left); + } + } + + // Right edge - only if mode allows horizontal wrapping + if ((wrapMode == WrapMode::Both || wrapMode == WrapMode::HorizontalOnly) && + cursorPos.x >= monitorRect.right - 1 - edgeThreshold) + { + auto it = m_edgeMap.find({monitorIndex, EdgeType::Right}); + if (it != m_edgeMap.end()) + { + if (it->second.isOuter) + { + candidateEdges.push_back(EdgeType::Right); + } + // Debug: Log why right edge isn't outer + else + { + Logger::trace(L"IsOnOuterEdge: Monitor {} right edge is NOT outer (inner edge)", monitorIndex); + } + } + } + + // Top edge - only if mode allows vertical wrapping + if ((wrapMode == WrapMode::Both || wrapMode == WrapMode::VerticalOnly) && + cursorPos.y <= monitorRect.top + edgeThreshold) + { + auto it = m_edgeMap.find({monitorIndex, EdgeType::Top}); + if (it != m_edgeMap.end() && it->second.isOuter) + { + candidateEdges.push_back(EdgeType::Top); + } + } + + // Bottom edge - only if mode allows vertical wrapping + if ((wrapMode == WrapMode::Both || wrapMode == WrapMode::VerticalOnly) && + cursorPos.y >= monitorRect.bottom - 1 - edgeThreshold) + { + auto it = m_edgeMap.find({monitorIndex, EdgeType::Bottom}); + if (it != m_edgeMap.end() && it->second.isOuter) + { + candidateEdges.push_back(EdgeType::Bottom); + } + } + + if (candidateEdges.empty()) + { + return false; + } + + // Try each candidate edge and return first with valid wrap destination + for (EdgeType candidate : candidateEdges) + { + MonitorEdge oppositeEdge = FindOppositeOuterEdge(candidate, + (candidate == EdgeType::Left || candidate == EdgeType::Right) ? cursorPos.y : cursorPos.x); + + if (oppositeEdge.monitorIndex >= 0) + { + outEdgeType = candidate; + return true; + } + } + + return false; +} + +POINT MonitorTopology::GetWrapDestination(HMONITOR fromMonitor, const POINT& cursorPos, EdgeType edgeType) const +{ + // Get monitor index for edge map lookup + int monitorIndex = GetMonitorIndex(fromMonitor); + if (monitorIndex < 0) + { + return cursorPos; // Monitor not found + } + + auto it = m_edgeMap.find({monitorIndex, edgeType}); + if (it == m_edgeMap.end()) + { + return cursorPos; // Edge not found + } + + const MonitorEdge& fromEdge = it->second; + + // Calculate relative position on current edge (0.0 to 1.0) + double relativePos = GetRelativePosition(fromEdge, + (edgeType == EdgeType::Left || edgeType == EdgeType::Right) ? cursorPos.y : cursorPos.x); + + // Find opposite outer edge + MonitorEdge oppositeEdge = FindOppositeOuterEdge(edgeType, + (edgeType == EdgeType::Left || edgeType == EdgeType::Right) ? cursorPos.y : cursorPos.x); + + if (oppositeEdge.monitorIndex < 0) + { + // No opposite edge found, wrap within same monitor + RECT monitorRect; + if (GetMonitorRect(fromMonitor, monitorRect)) + { + POINT result = cursorPos; + switch (edgeType) + { + case EdgeType::Left: + result.x = monitorRect.right - 2; + break; + case EdgeType::Right: + result.x = monitorRect.left + 1; + break; + case EdgeType::Top: + result.y = monitorRect.bottom - 2; + break; + case EdgeType::Bottom: + result.y = monitorRect.top + 1; + break; + } + return result; + } + return cursorPos; + } + + // Calculate target position on opposite edge + POINT result; + + if (edgeType == EdgeType::Left || edgeType == EdgeType::Right) + { + // Horizontal edge -> vertical movement + result.x = oppositeEdge.position; + result.y = GetAbsolutePosition(oppositeEdge, relativePos); + } + else + { + // Vertical edge -> horizontal movement + result.y = oppositeEdge.position; + result.x = GetAbsolutePosition(oppositeEdge, relativePos); + } + + return result; +} + +MonitorEdge MonitorTopology::FindOppositeOuterEdge(EdgeType fromEdge, int relativePosition) const +{ + EdgeType targetType; + bool findMax; // true = find max position, false = find min position + + switch (fromEdge) + { + case EdgeType::Left: + targetType = EdgeType::Right; + findMax = true; + break; + case EdgeType::Right: + targetType = EdgeType::Left; + findMax = false; + break; + case EdgeType::Top: + targetType = EdgeType::Bottom; + findMax = true; + break; + case EdgeType::Bottom: + targetType = EdgeType::Top; + findMax = false; + break; + default: + return { .monitorIndex = -1 }; // Invalid edge type + } + + MonitorEdge result = { .monitorIndex = -1 }; // -1 indicates not found + int extremePosition = findMax ? INT_MIN : INT_MAX; + + for (const auto& edge : m_outerEdges) + { + if (edge.type != targetType) + { + continue; + } + + // Check if this edge overlaps with the relative position + if (relativePosition >= edge.start && relativePosition <= edge.end) + { + if ((findMax && edge.position > extremePosition) || + (!findMax && edge.position < extremePosition)) + { + extremePosition = edge.position; + result = edge; + } + } + } + + return result; +} + +double MonitorTopology::GetRelativePosition(const MonitorEdge& edge, int coordinate) const +{ + if (edge.end == edge.start) + { + return 0.5; // Avoid division by zero + } + + int clamped = max(edge.start, min(coordinate, edge.end)); + // Use int64_t to avoid overflow warning C26451 + int64_t numerator = static_cast(clamped) - static_cast(edge.start); + int64_t denominator = static_cast(edge.end) - static_cast(edge.start); + return static_cast(numerator) / static_cast(denominator); +} + +int MonitorTopology::GetAbsolutePosition(const MonitorEdge& edge, double relativePosition) const +{ + // Use int64_t to prevent arithmetic overflow during subtraction and multiplication + int64_t range = static_cast(edge.end) - static_cast(edge.start); + int64_t offset = static_cast(relativePosition * static_cast(range)); + // Clamp result to int range before returning + int64_t result = static_cast(edge.start) + offset; + return static_cast(result); +} + +std::vector MonitorTopology::DetectMonitorGaps() const +{ + std::vector gaps; + const int gapThreshold = 50; // Same as ADJACENCY_TOLERANCE + + // Check each pair of monitors + for (size_t i = 0; i < m_monitors.size(); ++i) + { + for (size_t j = i + 1; j < m_monitors.size(); ++j) + { + const auto& m1 = m_monitors[i]; + const auto& m2 = m_monitors[j]; + + // Check vertical overlap + int vOverlapStart = max(m1.rect.top, m2.rect.top); + int vOverlapEnd = min(m1.rect.bottom, m2.rect.bottom); + int vOverlap = vOverlapEnd - vOverlapStart; + + if (vOverlap <= 0) + { + continue; // No vertical overlap, skip + } + + // Check horizontal gap + int hGap = min(abs(m1.rect.right - m2.rect.left), abs(m2.rect.right - m1.rect.left)); + + if (hGap > gapThreshold) + { + GapInfo gap; + gap.monitor1Index = static_cast(i); + gap.monitor2Index = static_cast(j); + gap.horizontalGap = hGap; + gap.verticalOverlap = vOverlap; + gaps.push_back(gap); + } + } + } + + return gaps; +} + +HMONITOR MonitorTopology::GetMonitorFromPoint(const POINT& pt) const +{ + return MonitorFromPoint(pt, MONITOR_DEFAULTTONEAREST); +} + +bool MonitorTopology::GetMonitorRect(HMONITOR monitor, RECT& rect) const +{ + // First try direct HMONITOR comparison + for (const auto& monitorInfo : m_monitors) + { + if (monitorInfo.hMonitor == monitor) + { + rect = monitorInfo.rect; + return true; + } + } + + // Fallback: If direct comparison fails, try matching by current monitor info + MONITORINFO mi{}; + mi.cbSize = sizeof(MONITORINFO); + if (GetMonitorInfo(monitor, &mi)) + { + for (const auto& monitorInfo : m_monitors) + { + if (monitorInfo.rect.left == mi.rcMonitor.left && + monitorInfo.rect.top == mi.rcMonitor.top && + monitorInfo.rect.right == mi.rcMonitor.right && + monitorInfo.rect.bottom == mi.rcMonitor.bottom) + { + rect = monitorInfo.rect; + return true; + } + } + } + + return false; +} + +HMONITOR MonitorTopology::GetMonitorFromRect(const RECT& rect) const +{ + return MonitorFromRect(&rect, MONITOR_DEFAULTTONEAREST); +} + +int MonitorTopology::GetMonitorIndex(HMONITOR monitor) const +{ + // First try direct HMONITOR comparison (fast and accurate) + for (size_t i = 0; i < m_monitors.size(); ++i) + { + if (m_monitors[i].hMonitor == monitor) + { + return static_cast(i); + } + } + + // Fallback: If direct comparison fails (e.g., handle changed after display reconfiguration), + // try matching by position. Get the monitor's current rect and find matching stored rect. + MONITORINFO mi{}; + mi.cbSize = sizeof(MONITORINFO); + if (GetMonitorInfo(monitor, &mi)) + { + for (size_t i = 0; i < m_monitors.size(); ++i) + { + // Match by rect bounds + if (m_monitors[i].rect.left == mi.rcMonitor.left && + m_monitors[i].rect.top == mi.rcMonitor.top && + m_monitors[i].rect.right == mi.rcMonitor.right && + m_monitors[i].rect.bottom == mi.rcMonitor.bottom) + { + Logger::trace(L"GetMonitorIndex: Found monitor {} via rect fallback (handle changed from {} to {})", + i, reinterpret_cast(m_monitors[i].hMonitor), reinterpret_cast(monitor)); + return static_cast(i); + } + } + + // Log all stored monitors vs the requested one for debugging + Logger::warn(L"GetMonitorIndex: No match found. Requested monitor rect=({},{},{},{})", + mi.rcMonitor.left, mi.rcMonitor.top, mi.rcMonitor.right, mi.rcMonitor.bottom); + for (size_t i = 0; i < m_monitors.size(); ++i) + { + Logger::warn(L" Stored monitor {}: rect=({},{},{},{})", + i, m_monitors[i].rect.left, m_monitors[i].rect.top, + m_monitors[i].rect.right, m_monitors[i].rect.bottom); + } + } + else + { + Logger::warn(L"GetMonitorIndex: GetMonitorInfo failed for handle {}", reinterpret_cast(monitor)); + } + + return -1; // Not found +} + diff --git a/src/modules/MouseUtils/CursorWrap/MonitorTopology.h b/src/modules/MouseUtils/CursorWrap/MonitorTopology.h new file mode 100644 index 0000000000..0dead8e351 --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/MonitorTopology.h @@ -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. + +#pragma once +#include +#include +#include + +// Monitor information structure +struct MonitorInfo +{ + HMONITOR hMonitor; // Direct handle for accurate lookup after display changes + RECT rect; + bool isPrimary; + int monitorId; +}; + +// Edge type enumeration +enum class EdgeType +{ + Left = 0, + Right = 1, + Top = 2, + Bottom = 3 +}; + +// Wrap mode enumeration (matches Settings UI dropdown) +enum class WrapMode +{ + Both = 0, // Wrap in both directions + VerticalOnly = 1, // Only wrap top/bottom + HorizontalOnly = 2 // Only wrap left/right +}; + +// Represents a single edge of a monitor +struct MonitorEdge +{ + int monitorIndex; // Index into m_monitors (stable across display changes) + EdgeType type; + int start; // For vertical edges: Y start; horizontal: X start + int end; // For vertical edges: Y end; horizontal: X end + int position; // For vertical edges: X coord; horizontal: Y coord + bool isOuter; // True if no adjacent monitor touches this edge +}; + +// Monitor topology helper - manages edge-based monitor layout +struct MonitorTopology +{ + void Initialize(const std::vector& monitors); + + // Check if cursor is on an outer edge of the given monitor + // wrapMode filters which edges are considered (Both, VerticalOnly, HorizontalOnly) + bool IsOnOuterEdge(HMONITOR monitor, const POINT& cursorPos, EdgeType& outEdgeType, WrapMode wrapMode) const; + + // Get the wrap destination point for a cursor on an outer edge + POINT GetWrapDestination(HMONITOR fromMonitor, const POINT& cursorPos, EdgeType edgeType) const; + + // Get monitor at point (helper) + HMONITOR GetMonitorFromPoint(const POINT& pt) const; + + // Get monitor rectangle (helper) + bool GetMonitorRect(HMONITOR monitor, RECT& rect) const; + + // Get outer edges collection (for debugging) + const std::vector& GetOuterEdges() const { return m_outerEdges; } + + // Detect gaps between monitors that should be snapped together + struct GapInfo { + int monitor1Index; + int monitor2Index; + int horizontalGap; + int verticalOverlap; + }; + std::vector DetectMonitorGaps() const; + +private: + std::vector m_monitors; + std::vector m_outerEdges; + + // Map from (monitor index, edge type) to edge info + // Using monitor index instead of HMONITOR because HMONITOR handles can change + // when monitors are added/removed dynamically + std::map, MonitorEdge> m_edgeMap; + + // Helper to resolve HMONITOR to monitor index at runtime + int GetMonitorIndex(HMONITOR monitor) const; + + // Helper to get consistent HMONITOR from RECT + HMONITOR GetMonitorFromRect(const RECT& rect) const; + + void BuildEdgeMap(); + void IdentifyOuterEdges(); + + // Check if two edges are adjacent (within tolerance) + bool EdgesAreAdjacent(const MonitorEdge& edge1, const MonitorEdge& edge2, int tolerance = 50) const; + + // Find the opposite outer edge for wrapping + MonitorEdge FindOppositeOuterEdge(EdgeType fromEdge, int relativePosition) const; + + // Calculate relative position along an edge (0.0 to 1.0) + double GetRelativePosition(const MonitorEdge& edge, int coordinate) const; + + // Convert relative position to absolute coordinate on target edge + int GetAbsolutePosition(const MonitorEdge& edge, double relativePosition) const; +}; diff --git a/src/modules/MouseUtils/CursorWrap/dllmain.cpp b/src/modules/MouseUtils/CursorWrap/dllmain.cpp index ee026b7b12..b172f1c8b6 100644 --- a/src/modules/MouseUtils/CursorWrap/dllmain.cpp +++ b/src/modules/MouseUtils/CursorWrap/dllmain.cpp @@ -1,3 +1,7 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + #include "pch.h" #include "../../../interface/powertoy_module_interface.h" #include "../../../common/SettingsAPI/settings_objects.h" @@ -14,8 +18,10 @@ #include #include #include +#include +#include #include "resource.h" -#include "CursorWrapTests.h" +#include "CursorWrapCore.h" // Disable C26451 arithmetic overflow warning for this file since the operations are safe in this context #pragma warning(disable: 26451) @@ -47,6 +53,8 @@ namespace const wchar_t JSON_KEY_ACTIVATION_SHORTCUT[] = L"activation_shortcut"; const wchar_t JSON_KEY_AUTO_ACTIVATE[] = L"auto_activate"; const wchar_t JSON_KEY_DISABLE_WRAP_DURING_DRAG[] = L"disable_wrap_during_drag"; + const wchar_t JSON_KEY_WRAP_MODE[] = L"wrap_mode"; + const wchar_t JSON_KEY_DISABLE_ON_SINGLE_MONITOR[] = L"disable_cursor_wrap_on_single_monitor"; } // The PowerToy name that will be shown in the settings. @@ -54,34 +62,10 @@ const static wchar_t* MODULE_NAME = L"CursorWrap"; // Add a description that will we shown in the module settings page. const static wchar_t* MODULE_DESC = L""; -// Mouse hook data structure -struct MonitorInfo -{ - RECT rect; - bool isPrimary; - int monitorId; // Add monitor ID for easier debugging -}; - -// Add structure for logical monitor grid position -struct LogicalPosition -{ - int row; - int col; - bool isValid; -}; - -// Add monitor topology helper -struct MonitorTopology -{ - std::vector> grid; // 3x3 grid of monitors - std::map monitorToPosition; - std::map, HMONITOR> positionToMonitor; - - void Initialize(const std::vector& monitors); - LogicalPosition GetPosition(HMONITOR monitor) const; - HMONITOR GetMonitorAt(int row, int col) const; - HMONITOR FindAdjacentMonitor(HMONITOR current, int deltaRow, int deltaCol) const; -}; +// Monitor device interface GUID for RegisterDeviceNotification +// {e6f07b5f-ee97-4a90-b076-33f57bf4eaa7} +static const GUID GUID_DEVINTERFACE_MONITOR = + { 0xe6f07b5f, 0xee97, 0x4a90, { 0xb0, 0x76, 0x33, 0xf5, 0x7b, 0xf4, 0xea, 0xa7 } }; // Forward declaration class CursorWrap; @@ -97,14 +81,15 @@ private: bool m_enabled = false; bool m_autoActivate = false; bool m_disableWrapDuringDrag = true; // Default to true to prevent wrap during drag + bool m_disableOnSingleMonitor = false; // Default to false + int m_wrapMode = 0; // 0=Both (default), 1=VerticalOnly, 2=HorizontalOnly // Mouse hook HHOOK m_mouseHook = nullptr; std::atomic m_hookActive{ false }; - // Monitor information - std::vector m_monitors; - MonitorTopology m_topology; + // Core wrapping engine (edge-based polygon model) + CursorWrapCore m_core; // Hotkey Hotkey m_activationHotkey{}; @@ -115,13 +100,19 @@ private: std::thread m_eventThread; std::atomic_bool m_listening{ false }; + // Display change notification + HWND m_messageWindow = nullptr; + HDEVNOTIFY m_deviceNotify = nullptr; + static constexpr UINT_PTR TIMER_UPDATE_MONITORS = 1; + static constexpr UINT DEBOUNCE_DELAY_MS = 500; + public: // Constructor CursorWrap() { LoggerHelpers::init_logger(MODULE_NAME, L"ModuleInterface", LogSettings::cursorWrapLoggerName); init_settings(); - UpdateMonitorInfo(); + m_core.UpdateMonitorInfo(); g_cursorWrapInstance = this; // Set global instance pointer }; @@ -207,6 +198,10 @@ public: // Start listening for external trigger event so we can invoke the same logic as the activation hotkey. m_triggerEventHandle = CreateEventW(nullptr, false, false, CommonSharedConstants::CURSOR_WRAP_TRIGGER_EVENT); m_terminateEventHandle = CreateEventW(nullptr, false, false, nullptr); + if (m_triggerEventHandle) + { + ResetEvent(m_triggerEventHandle); + } if (m_triggerEventHandle && m_terminateEventHandle) { m_listening = true; @@ -218,8 +213,19 @@ public: MSG msg; PeekMessage(&msg, nullptr, WM_USER, WM_USER, PM_NOREMOVE); - StartMouseHook(); - Logger::info("CursorWrap enabled - mouse hook started"); + // Create message window for display change notifications + RegisterForDisplayChanges(); + + // Only start the mouse hook automatically if auto-activate is enabled + if (m_autoActivate) + { + StartMouseHook(); + Logger::info("CursorWrap enabled - mouse hook started (auto-activate on)"); + } + else + { + Logger::info("CursorWrap enabled - waiting for activation hotkey (auto-activate off)"); + } while (m_listening) { @@ -247,6 +253,9 @@ public: } } + // Cleanup display change notifications + UnregisterDisplayChanges(); + StopMouseHook(); Logger::info("CursorWrap event listener stopped"); }); @@ -318,7 +327,17 @@ public: return false; } - private: + // Called when display configuration changes - update monitor topology + void OnDisplayChange() + { +#ifdef _DEBUG + OutputDebugStringW(L"[CursorWrap] Display configuration changed, updating monitor topology\n"); +#endif + Logger::info("Display configuration changed, updating monitor topology"); + m_core.UpdateMonitorInfo(); + } + +private: void ToggleMouseHook() { // Toggle cursor wrapping. @@ -329,10 +348,6 @@ public: else { StartMouseHook(); -#ifdef _DEBUG - // Run comprehensive tests when hook is started in debug builds - RunComprehensiveTests(); -#endif } } @@ -399,6 +414,36 @@ public: { Logger::warn("Failed to initialize CursorWrap disable wrap during drag from settings. Will use default value (true)"); } + + try + { + // Parse wrap mode + auto propertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES); + if (propertiesObject.HasKey(JSON_KEY_WRAP_MODE)) + { + auto wrapModeObject = propertiesObject.GetNamedObject(JSON_KEY_WRAP_MODE); + m_wrapMode = static_cast(wrapModeObject.GetNamedNumber(JSON_KEY_VALUE)); + } + } + catch (...) + { + Logger::warn("Failed to initialize CursorWrap wrap mode from settings. Will use default value (0=Both)"); + } + + try + { + // Parse disable on single monitor + auto propertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES); + if (propertiesObject.HasKey(JSON_KEY_DISABLE_ON_SINGLE_MONITOR)) + { + auto disableOnSingleMonitorObject = propertiesObject.GetNamedObject(JSON_KEY_DISABLE_ON_SINGLE_MONITOR); + m_disableOnSingleMonitor = disableOnSingleMonitorObject.GetNamedBoolean(JSON_KEY_VALUE); + } + } + catch (...) + { + Logger::warn("Failed to initialize CursorWrap disable on single monitor from settings. Will use default value (false)"); + } } else { @@ -416,31 +461,6 @@ public: } } - void UpdateMonitorInfo() - { - m_monitors.clear(); - - EnumDisplayMonitors(nullptr, nullptr, [](HMONITOR hMonitor, HDC, LPRECT, LPARAM lParam) -> BOOL { - auto* self = reinterpret_cast(lParam); - - MONITORINFO mi{}; - mi.cbSize = sizeof(MONITORINFO); - if (GetMonitorInfo(hMonitor, &mi)) - { - MonitorInfo info{}; - info.rect = mi.rcMonitor; - info.isPrimary = (mi.dwFlags & MONITORINFOF_PRIMARY) != 0; - info.monitorId = static_cast(self->m_monitors.size()); - self->m_monitors.push_back(info); - } - - return TRUE; - }, reinterpret_cast(this)); - - // Initialize monitor topology - m_topology.Initialize(m_monitors); - } - void StartMouseHook() { if (m_mouseHook || m_hookActive) @@ -449,7 +469,8 @@ public: return; } - UpdateMonitorInfo(); + // Refresh monitor info before starting hook + m_core.UpdateMonitorInfo(); m_mouseHook = SetWindowsHookEx(WH_MOUSE_LL, MouseHookProc, GetModuleHandle(nullptr), 0); if (m_mouseHook) @@ -481,6 +502,167 @@ public: } } + void RegisterForDisplayChanges() + { + if (m_messageWindow) + { + return; // Already registered + } + + // Create a hidden top-level window to receive broadcast messages + // NOTE: Message-only windows (HWND_MESSAGE parent) do NOT receive + // WM_DISPLAYCHANGE, WM_SETTINGCHANGE, or WM_DEVICECHANGE broadcasts. + // We must use a real (hidden) top-level window instead. + WNDCLASSEXW wc = { sizeof(WNDCLASSEXW) }; + wc.lpfnWndProc = MessageWindowProc; + wc.hInstance = GetModuleHandle(nullptr); + wc.lpszClassName = L"CursorWrapDisplayChangeWindow"; + + RegisterClassExW(&wc); + + // Create a hidden top-level window (not message-only) + // WS_EX_TOOLWINDOW prevents taskbar button, WS_POPUP with no size makes it invisible + m_messageWindow = CreateWindowExW( + WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE, + L"CursorWrapDisplayChangeWindow", + nullptr, + WS_POPUP, // Minimal window style + 0, 0, 0, 0, // Zero size = invisible + nullptr, // No parent - top-level window to receive broadcasts + nullptr, + GetModuleHandle(nullptr), + nullptr); + + if (m_messageWindow) + { +#ifdef _DEBUG + OutputDebugStringW(L"[CursorWrap] Registered for display change notifications\n"); +#endif + Logger::info("Registered for display change notifications"); + + // Register for device notifications (monitor hardware add/remove) + DEV_BROADCAST_DEVICEINTERFACE filter = {}; + filter.dbcc_size = sizeof(DEV_BROADCAST_DEVICEINTERFACE); + filter.dbcc_devicetype = DBT_DEVTYP_DEVICEINTERFACE; + filter.dbcc_classguid = GUID_DEVINTERFACE_MONITOR; + + m_deviceNotify = RegisterDeviceNotificationW( + m_messageWindow, + &filter, + DEVICE_NOTIFY_WINDOW_HANDLE); + + if (m_deviceNotify) + { +#ifdef _DEBUG + OutputDebugStringW(L"[CursorWrap] Registered for device notifications (monitor hardware changes)\n"); +#endif + Logger::info("Registered for device notifications (monitor hardware changes)"); + } + else + { + DWORD error = GetLastError(); +#ifdef _DEBUG + std::wostringstream oss; + oss << L"[CursorWrap] Failed to register device notifications. Error: " << error << L"\n"; + OutputDebugStringW(oss.str().c_str()); +#endif + Logger::warn("Failed to register device notifications. Error: {}", error); + } + } + else + { + DWORD error = GetLastError(); + Logger::error(L"Failed to create message window for display changes, error: {}", error); + } + } + + void UnregisterDisplayChanges() + { + if (m_deviceNotify) + { +#ifdef _DEBUG + OutputDebugStringW(L"[CursorWrap] Unregistering device notifications...\n"); +#endif + UnregisterDeviceNotification(m_deviceNotify); + m_deviceNotify = nullptr; + Logger::info("Unregistered device notifications"); + } + + if (m_messageWindow) + { + KillTimer(m_messageWindow, TIMER_UPDATE_MONITORS); + DestroyWindow(m_messageWindow); + m_messageWindow = nullptr; + UnregisterClassW(L"CursorWrapDisplayChangeWindow", GetModuleHandle(nullptr)); +#ifdef _DEBUG + OutputDebugStringW(L"[CursorWrap] Unregistered display change notifications\n"); +#endif + Logger::info("Unregistered display change notifications"); + } + } + + static LRESULT CALLBACK MessageWindowProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) + { + if (!g_cursorWrapInstance) + { + return DefWindowProcW(hwnd, msg, wParam, lParam); + } + + switch (msg) + { + case WM_DISPLAYCHANGE: +#ifdef _DEBUG + OutputDebugStringW(L"[CursorWrap] WM_DISPLAYCHANGE received - monitor resolution/DPI changed\n"); +#endif + Logger::info("WM_DISPLAYCHANGE received - resolution/DPI changed"); + // Debounce: Wait for multiple changes to settle + KillTimer(hwnd, TIMER_UPDATE_MONITORS); + SetTimer(hwnd, TIMER_UPDATE_MONITORS, DEBOUNCE_DELAY_MS, nullptr); + break; + + case WM_SETTINGCHANGE: + if (wParam == SPI_SETWORKAREA) + { +#ifdef _DEBUG + OutputDebugStringW(L"[CursorWrap] WM_SETTINGCHANGE (SPI_SETWORKAREA) received - taskbar changed\n"); +#endif + Logger::info("WM_SETTINGCHANGE (SPI_SETWORKAREA) received"); + // Taskbar position/size changed + KillTimer(hwnd, TIMER_UPDATE_MONITORS); + SetTimer(hwnd, TIMER_UPDATE_MONITORS, DEBOUNCE_DELAY_MS, nullptr); + } + break; + + case WM_DEVICECHANGE: + // Handle monitor hardware add/remove + if (wParam == DBT_DEVNODES_CHANGED) + { +#ifdef _DEBUG + OutputDebugStringW(L"[CursorWrap] DBT_DEVNODES_CHANGED received - monitor hardware change detected\n"); +#endif + Logger::info("DBT_DEVNODES_CHANGED received - monitor hardware change detected"); + // Debounce: Wait for multiple changes to settle + KillTimer(hwnd, TIMER_UPDATE_MONITORS); + SetTimer(hwnd, TIMER_UPDATE_MONITORS, DEBOUNCE_DELAY_MS, nullptr); + return TRUE; + } + break; + + case WM_TIMER: + if (wParam == TIMER_UPDATE_MONITORS) + { +#ifdef _DEBUG + OutputDebugStringW(L"[CursorWrap] Debounce timer expired - triggering topology update\n"); +#endif + KillTimer(hwnd, TIMER_UPDATE_MONITORS); + g_cursorWrapInstance->OnDisplayChange(); + } + break; + } + + return DefWindowProcW(hwnd, msg, wParam, lParam); + } + static LRESULT CALLBACK MouseHookProc(int nCode, WPARAM wParam, LPARAM lParam) { if (nCode >= 0 && wParam == WM_MOUSEMOVE) @@ -490,7 +672,12 @@ public: if (g_cursorWrapInstance && g_cursorWrapInstance->m_hookActive) { - POINT newPos = g_cursorWrapInstance->HandleMouseMove(currentPos); + POINT newPos = g_cursorWrapInstance->m_core.HandleMouseMove( + currentPos, + g_cursorWrapInstance->m_disableWrapDuringDrag, + g_cursorWrapInstance->m_wrapMode, + g_cursorWrapInstance->m_disableOnSingleMonitor); + if (newPos.x != currentPos.x || newPos.y != currentPos.y) { #ifdef _DEBUG @@ -505,765 +692,8 @@ public: return CallNextHookEx(nullptr, nCode, wParam, lParam); } - - // Helper method to check if there's a monitor adjacent in coordinate space (not grid) - bool HasAdjacentMonitorInCoordinateSpace(const RECT& currentMonitorRect, int direction) - { - // direction: 0=left, 1=right, 2=top, 3=bottom - const int tolerance = 50; // Allow small gaps - - for (const auto& monitor : m_monitors) - { - bool isAdjacent = false; - - switch (direction) - { - case 0: // Left - check if another monitor's right edge touches/overlaps our left edge - isAdjacent = (abs(monitor.rect.right - currentMonitorRect.left) <= tolerance) && - (monitor.rect.bottom > currentMonitorRect.top + tolerance) && - (monitor.rect.top < currentMonitorRect.bottom - tolerance); - break; - - case 1: // Right - check if another monitor's left edge touches/overlaps our right edge - isAdjacent = (abs(monitor.rect.left - currentMonitorRect.right) <= tolerance) && - (monitor.rect.bottom > currentMonitorRect.top + tolerance) && - (monitor.rect.top < currentMonitorRect.bottom - tolerance); - break; - - case 2: // Top - check if another monitor's bottom edge touches/overlaps our top edge - isAdjacent = (abs(monitor.rect.bottom - currentMonitorRect.top) <= tolerance) && - (monitor.rect.right > currentMonitorRect.left + tolerance) && - (monitor.rect.left < currentMonitorRect.right - tolerance); - break; - - case 3: // Bottom - check if another monitor's top edge touches/overlaps our bottom edge - isAdjacent = (abs(monitor.rect.top - currentMonitorRect.bottom) <= tolerance) && - (monitor.rect.right > currentMonitorRect.left + tolerance) && - (monitor.rect.left < currentMonitorRect.right - tolerance); - break; - } - - if (isAdjacent) - { -#ifdef _DEBUG - Logger::info(L"CursorWrap DEBUG: Found adjacent monitor in coordinate space (direction {})", direction); -#endif - return true; - } - } - - return false; - } - - // *** COMPLETELY REWRITTEN CURSOR WRAPPING LOGIC *** - // Implements vertical scrolling to bottom/top of vertical stack as requested - // Only wraps when there's NO adjacent monitor in the coordinate space - POINT HandleMouseMove(const POINT& currentPos) - { - POINT newPos = currentPos; - - // Check if we should skip wrapping during drag if the setting is enabled - if (m_disableWrapDuringDrag && (GetAsyncKeyState(VK_LBUTTON) & 0x8000)) - { -#ifdef _DEBUG - Logger::info(L"CursorWrap DEBUG: Left mouse button is down and disable_wrap_during_drag is enabled - skipping wrap"); -#endif - return currentPos; // Return unchanged position (no wrapping) - } - -#ifdef _DEBUG - Logger::info(L"CursorWrap DEBUG: ======= HANDLE MOUSE MOVE START ======="); - Logger::info(L"CursorWrap DEBUG: Input position ({}, {})", currentPos.x, currentPos.y); -#endif - - // Find which monitor the cursor is currently on - HMONITOR currentMonitor = MonitorFromPoint(currentPos, MONITOR_DEFAULTTONEAREST); - MONITORINFO currentMonitorInfo{}; - currentMonitorInfo.cbSize = sizeof(MONITORINFO); - GetMonitorInfo(currentMonitor, ¤tMonitorInfo); - - LogicalPosition currentLogicalPos = m_topology.GetPosition(currentMonitor); - -#ifdef _DEBUG - Logger::info(L"CursorWrap DEBUG: Current monitor bounds: Left={}, Top={}, Right={}, Bottom={}", - currentMonitorInfo.rcMonitor.left, currentMonitorInfo.rcMonitor.top, - currentMonitorInfo.rcMonitor.right, currentMonitorInfo.rcMonitor.bottom); - Logger::info(L"CursorWrap DEBUG: Logical position: Row={}, Col={}, Valid={}", - currentLogicalPos.row, currentLogicalPos.col, currentLogicalPos.isValid); -#endif - - bool wrapped = false; - - // *** VERTICAL WRAPPING LOGIC - CONFIRMED WORKING *** - // Move to bottom of vertical stack when hitting top edge - // Only wrap if there's NO adjacent monitor in the coordinate space - if (currentPos.y <= currentMonitorInfo.rcMonitor.top) - { -#ifdef _DEBUG - Logger::info(L"CursorWrap DEBUG: ======= VERTICAL WRAP: TOP EDGE DETECTED ======="); -#endif - - // Check if there's an adjacent monitor above in coordinate space - if (HasAdjacentMonitorInCoordinateSpace(currentMonitorInfo.rcMonitor, 2)) - { -#ifdef _DEBUG - Logger::info(L"CursorWrap DEBUG: SKIPPING WRAP - Adjacent monitor exists above (Windows will handle)"); -#endif - return currentPos; // Let Windows handle natural cursor movement - } - - // Find the bottom-most monitor in the vertical stack (same column) - HMONITOR bottomMonitor = nullptr; - - if (currentLogicalPos.isValid) { - // Search down from current position to find the bottom-most monitor in same column - for (int row = 2; row >= 0; row--) { // Start from bottom and work up - HMONITOR candidateMonitor = m_topology.GetMonitorAt(row, currentLogicalPos.col); - if (candidateMonitor) { - bottomMonitor = candidateMonitor; - break; // Found the bottom-most monitor - } - } - } - - if (bottomMonitor && bottomMonitor != currentMonitor) { - // *** MOVE TO BOTTOM OF VERTICAL STACK *** - MONITORINFO bottomInfo{}; - bottomInfo.cbSize = sizeof(MONITORINFO); - GetMonitorInfo(bottomMonitor, &bottomInfo); - - // Calculate relative X position to maintain cursor X alignment - double relativeX = static_cast(currentPos.x - currentMonitorInfo.rcMonitor.left) / - (currentMonitorInfo.rcMonitor.right - currentMonitorInfo.rcMonitor.left); - - int targetWidth = bottomInfo.rcMonitor.right - bottomInfo.rcMonitor.left; - newPos.x = bottomInfo.rcMonitor.left + static_cast(relativeX * targetWidth); - newPos.y = bottomInfo.rcMonitor.bottom - 1; // Bottom edge of bottom monitor - - // Clamp X to target monitor bounds - newPos.x = max(bottomInfo.rcMonitor.left, min(newPos.x, bottomInfo.rcMonitor.right - 1)); - wrapped = true; - -#ifdef _DEBUG - Logger::info(L"CursorWrap DEBUG: VERTICAL WRAP SUCCESS - Moved to bottom of vertical stack"); - Logger::info(L"CursorWrap DEBUG: New position: ({}, {})", newPos.x, newPos.y); -#endif - } else { - // *** NO OTHER MONITOR IN VERTICAL STACK - WRAP WITHIN CURRENT MONITOR *** - newPos.y = currentMonitorInfo.rcMonitor.bottom - 1; - wrapped = true; - -#ifdef _DEBUG - Logger::info(L"CursorWrap DEBUG: VERTICAL WRAP - No other monitor in stack, wrapping within current monitor"); -#endif - } - } - else if (currentPos.y >= currentMonitorInfo.rcMonitor.bottom - 1) - { -#ifdef _DEBUG - Logger::info(L"CursorWrap DEBUG: ======= VERTICAL WRAP: BOTTOM EDGE DETECTED ======="); -#endif - - // Check if there's an adjacent monitor below in coordinate space - if (HasAdjacentMonitorInCoordinateSpace(currentMonitorInfo.rcMonitor, 3)) - { -#ifdef _DEBUG - Logger::info(L"CursorWrap DEBUG: SKIPPING WRAP - Adjacent monitor exists below (Windows will handle)"); -#endif - return currentPos; // Let Windows handle natural cursor movement - } - - // Find the top-most monitor in the vertical stack (same column) - HMONITOR topMonitor = nullptr; - - if (currentLogicalPos.isValid) { - // Search up from current position to find the top-most monitor in same column - for (int row = 0; row <= 2; row++) { // Start from top and work down - HMONITOR candidateMonitor = m_topology.GetMonitorAt(row, currentLogicalPos.col); - if (candidateMonitor) { - topMonitor = candidateMonitor; - break; // Found the top-most monitor - } - } - } - - if (topMonitor && topMonitor != currentMonitor) { - // *** MOVE TO TOP OF VERTICAL STACK *** - MONITORINFO topInfo{}; - topInfo.cbSize = sizeof(MONITORINFO); - GetMonitorInfo(topMonitor, &topInfo); - - // Calculate relative X position to maintain cursor X alignment - double relativeX = static_cast(currentPos.x - currentMonitorInfo.rcMonitor.left) / - (currentMonitorInfo.rcMonitor.right - currentMonitorInfo.rcMonitor.left); - - int targetWidth = topInfo.rcMonitor.right - topInfo.rcMonitor.left; - newPos.x = topInfo.rcMonitor.left + static_cast(relativeX * targetWidth); - newPos.y = topInfo.rcMonitor.top; // Top edge of top monitor - - // Clamp X to target monitor bounds - newPos.x = max(topInfo.rcMonitor.left, min(newPos.x, topInfo.rcMonitor.right - 1)); - wrapped = true; - -#ifdef _DEBUG - Logger::info(L"CursorWrap DEBUG: VERTICAL WRAP SUCCESS - Moved to top of vertical stack"); - Logger::info(L"CursorWrap DEBUG: New position: ({}, {})", newPos.x, newPos.y); -#endif - } else { - // *** NO OTHER MONITOR IN VERTICAL STACK - WRAP WITHIN CURRENT MONITOR *** - newPos.y = currentMonitorInfo.rcMonitor.top; - wrapped = true; - -#ifdef _DEBUG - Logger::info(L"CursorWrap DEBUG: VERTICAL WRAP - No other monitor in stack, wrapping within current monitor"); -#endif - } - } - - // *** FIXED HORIZONTAL WRAPPING LOGIC *** - // Move to opposite end of horizontal stack when hitting left/right edge - // Only wrap if there's NO adjacent monitor in the coordinate space (let Windows handle natural transitions) - if (!wrapped && currentPos.x <= currentMonitorInfo.rcMonitor.left) - { -#ifdef _DEBUG - Logger::info(L"CursorWrap DEBUG: ======= HORIZONTAL WRAP: LEFT EDGE DETECTED ======="); -#endif - - // Check if there's an adjacent monitor to the left in coordinate space - if (HasAdjacentMonitorInCoordinateSpace(currentMonitorInfo.rcMonitor, 0)) - { -#ifdef _DEBUG - Logger::info(L"CursorWrap DEBUG: SKIPPING WRAP - Adjacent monitor exists to the left (Windows will handle)"); -#endif - return currentPos; // Let Windows handle natural cursor movement - } - - // Find the right-most monitor in the horizontal stack (same row) - HMONITOR rightMonitor = nullptr; - - if (currentLogicalPos.isValid) { - // Search right from current position to find the right-most monitor in same row - for (int col = 2; col >= 0; col--) { // Start from right and work left - HMONITOR candidateMonitor = m_topology.GetMonitorAt(currentLogicalPos.row, col); - if (candidateMonitor) { - rightMonitor = candidateMonitor; - break; // Found the right-most monitor - } - } - } - - if (rightMonitor && rightMonitor != currentMonitor) { - // *** MOVE TO RIGHT END OF HORIZONTAL STACK *** - MONITORINFO rightInfo{}; - rightInfo.cbSize = sizeof(MONITORINFO); - GetMonitorInfo(rightMonitor, &rightInfo); - - // Calculate relative Y position to maintain cursor Y alignment - double relativeY = static_cast(currentPos.y - currentMonitorInfo.rcMonitor.top) / - (currentMonitorInfo.rcMonitor.bottom - currentMonitorInfo.rcMonitor.top); - - int targetHeight = rightInfo.rcMonitor.bottom - rightInfo.rcMonitor.top; - newPos.y = rightInfo.rcMonitor.top + static_cast(relativeY * targetHeight); - newPos.x = rightInfo.rcMonitor.right - 1; // Right edge of right monitor - - // Clamp Y to target monitor bounds - newPos.y = max(rightInfo.rcMonitor.top, min(newPos.y, rightInfo.rcMonitor.bottom - 1)); - wrapped = true; - -#ifdef _DEBUG - Logger::info(L"CursorWrap DEBUG: HORIZONTAL WRAP SUCCESS - Moved to right end of horizontal stack"); - Logger::info(L"CursorWrap DEBUG: New position: ({}, {})", newPos.x, newPos.y); -#endif - } else { - // *** NO OTHER MONITOR IN HORIZONTAL STACK - WRAP WITHIN CURRENT MONITOR *** - newPos.x = currentMonitorInfo.rcMonitor.right - 1; - wrapped = true; - -#ifdef _DEBUG - Logger::info(L"CursorWrap DEBUG: HORIZONTAL WRAP - No other monitor in stack, wrapping within current monitor"); -#endif - } - } - else if (!wrapped && currentPos.x >= currentMonitorInfo.rcMonitor.right - 1) - { -#ifdef _DEBUG - Logger::info(L"CursorWrap DEBUG: ======= HORIZONTAL WRAP: RIGHT EDGE DETECTED ======="); -#endif - - // Check if there's an adjacent monitor to the right in coordinate space - if (HasAdjacentMonitorInCoordinateSpace(currentMonitorInfo.rcMonitor, 1)) - { -#ifdef _DEBUG - Logger::info(L"CursorWrap DEBUG: SKIPPING WRAP - Adjacent monitor exists to the right (Windows will handle)"); -#endif - return currentPos; // Let Windows handle natural cursor movement - } - - // Find the left-most monitor in the horizontal stack (same row) - HMONITOR leftMonitor = nullptr; - - if (currentLogicalPos.isValid) { - // Search left from current position to find the left-most monitor in same row - for (int col = 0; col <= 2; col++) { // Start from left and work right - HMONITOR candidateMonitor = m_topology.GetMonitorAt(currentLogicalPos.row, col); - if (candidateMonitor) { - leftMonitor = candidateMonitor; - break; // Found the left-most monitor - } - } - } - - if (leftMonitor && leftMonitor != currentMonitor) { - // *** MOVE TO LEFT END OF HORIZONTAL STACK *** - MONITORINFO leftInfo{}; - leftInfo.cbSize = sizeof(MONITORINFO); - GetMonitorInfo(leftMonitor, &leftInfo); - - // Calculate relative Y position to maintain cursor Y alignment - double relativeY = static_cast(currentPos.y - currentMonitorInfo.rcMonitor.top) / - (currentMonitorInfo.rcMonitor.bottom - currentMonitorInfo.rcMonitor.top); - - int targetHeight = leftInfo.rcMonitor.bottom - leftInfo.rcMonitor.top; - newPos.y = leftInfo.rcMonitor.top + static_cast(relativeY * targetHeight); - newPos.x = leftInfo.rcMonitor.left; // Left edge of left monitor - - // Clamp Y to target monitor bounds - newPos.y = max(leftInfo.rcMonitor.top, min(newPos.y, leftInfo.rcMonitor.bottom - 1)); - wrapped = true; - -#ifdef _DEBUG - Logger::info(L"CursorWrap DEBUG: HORIZONTAL WRAP SUCCESS - Moved to left end of horizontal stack"); - Logger::info(L"CursorWrap DEBUG: New position: ({}, {})", newPos.x, newPos.y); -#endif - } else { - // *** NO OTHER MONITOR IN HORIZONTAL STACK - WRAP WITHIN CURRENT MONITOR *** - newPos.x = currentMonitorInfo.rcMonitor.left; - wrapped = true; - -#ifdef _DEBUG - Logger::info(L"CursorWrap DEBUG: HORIZONTAL WRAP - No other monitor in stack, wrapping within current monitor"); -#endif - } - } - -#ifdef _DEBUG - if (wrapped) - { - Logger::info(L"CursorWrap DEBUG: ======= WRAP RESULT ======="); - Logger::info(L"CursorWrap DEBUG: Original: ({}, {}) -> New: ({}, {})", - currentPos.x, currentPos.y, newPos.x, newPos.y); - } - else - { - Logger::info(L"CursorWrap DEBUG: No wrapping performed - cursor not at edge"); - } - Logger::info(L"CursorWrap DEBUG: ======= HANDLE MOUSE MOVE END ======="); -#endif - - return newPos; - } - - // Add test method for monitor topology validation - void RunMonitorTopologyTests() - { -#ifdef _DEBUG - Logger::info(L"CursorWrap: Running monitor topology tests..."); - - // Test all 9 possible monitor positions in 3x3 grid - const char* gridNames[3][3] = { - {"TL", "TC", "TR"}, // Top-Left, Top-Center, Top-Right - {"ML", "MC", "MR"}, // Middle-Left, Middle-Center, Middle-Right - {"BL", "BC", "BR"} // Bottom-Left, Bottom-Center, Bottom-Right - }; - - for (int row = 0; row < 3; row++) - { - for (int col = 0; col < 3; col++) - { - HMONITOR monitor = m_topology.GetMonitorAt(row, col); - if (monitor) - { - std::string gridName(gridNames[row][col]); - std::wstring wGridName(gridName.begin(), gridName.end()); - Logger::info(L"CursorWrap TEST: Monitor at [{}][{}] ({}) exists", - row, col, wGridName.c_str()); - - // Test adjacent monitor finding - HMONITOR up = m_topology.FindAdjacentMonitor(monitor, -1, 0); - HMONITOR down = m_topology.FindAdjacentMonitor(monitor, 1, 0); - HMONITOR left = m_topology.FindAdjacentMonitor(monitor, 0, -1); - HMONITOR right = m_topology.FindAdjacentMonitor(monitor, 0, 1); - - Logger::info(L"CursorWrap TEST: Adjacent monitors - Up: {}, Down: {}, Left: {}, Right: {}", - up ? L"YES" : L"NO", down ? L"YES" : L"NO", - left ? L"YES" : L"NO", right ? L"YES" : L"NO"); - } - } - } - - Logger::info(L"CursorWrap: Monitor topology tests completed."); -#endif - } - - // Add method to trigger test suite (can be called via hotkey in debug builds) - void RunComprehensiveTests() - { -#ifdef _DEBUG - RunMonitorTopologyTests(); - - // Test cursor wrapping scenarios - Logger::info(L"CursorWrap: Testing cursor wrapping scenarios..."); - - // Simulate cursor positions at each monitor edge and verify expected behavior - for (const auto& monitor : m_monitors) - { - HMONITOR hMonitor = MonitorFromRect(&monitor.rect, MONITOR_DEFAULTTONEAREST); - LogicalPosition pos = m_topology.GetPosition(hMonitor); - - if (pos.isValid) - { - Logger::info(L"CursorWrap TEST: Testing monitor at position [{}][{}]", pos.row, pos.col); - - // Test top edge - POINT topEdge = {(monitor.rect.left + monitor.rect.right) / 2, monitor.rect.top}; - POINT newPos = HandleMouseMove(topEdge); - Logger::info(L"CursorWrap TEST: Top edge ({}, {}) -> ({}, {})", - topEdge.x, topEdge.y, newPos.x, newPos.y); - - // Test bottom edge - POINT bottomEdge = {(monitor.rect.left + monitor.rect.right) / 2, monitor.rect.bottom - 1}; - newPos = HandleMouseMove(bottomEdge); - Logger::info(L"CursorWrap TEST: Bottom edge ({}, {}) -> ({}, {})", - bottomEdge.x, bottomEdge.y, newPos.x, newPos.y); - - // Test left edge - POINT leftEdge = {monitor.rect.left, (monitor.rect.top + monitor.rect.bottom) / 2}; - newPos = HandleMouseMove(leftEdge); - Logger::info(L"CursorWrap TEST: Left edge ({}, {}) -> ({}, {})", - leftEdge.x, leftEdge.y, newPos.x, newPos.y); - - // Test right edge - POINT rightEdge = {monitor.rect.right - 1, (monitor.rect.top + monitor.rect.bottom) / 2}; - newPos = HandleMouseMove(rightEdge); - Logger::info(L"CursorWrap TEST: Right edge ({}, {}) -> ({}, {})", - rightEdge.x, rightEdge.y, newPos.x, newPos.y); - } - } - - Logger::info(L"CursorWrap: Comprehensive tests completed."); -#endif - } }; -// Implementation of MonitorTopology methods -void MonitorTopology::Initialize(const std::vector& monitors) -{ - // Clear existing data - grid.assign(3, std::vector(3, nullptr)); - monitorToPosition.clear(); - positionToMonitor.clear(); - - if (monitors.empty()) return; - -#ifdef _DEBUG - Logger::info(L"CursorWrap DEBUG: ======= TOPOLOGY INITIALIZATION START ======="); - Logger::info(L"CursorWrap DEBUG: Initializing topology for {} monitors", monitors.size()); - for (const auto& monitor : monitors) - { - Logger::info(L"CursorWrap DEBUG: Monitor {}: bounds=({},{},{},{}), isPrimary={}", - monitor.monitorId, monitor.rect.left, monitor.rect.top, - monitor.rect.right, monitor.rect.bottom, monitor.isPrimary); - } -#endif - - // Special handling for 2 monitors - use physical position, not discovery order - if (monitors.size() == 2) - { - // Determine if arrangement is horizontal or vertical by comparing centers - POINT center0 = {(monitors[0].rect.left + monitors[0].rect.right) / 2, - (monitors[0].rect.top + monitors[0].rect.bottom) / 2}; - POINT center1 = {(monitors[1].rect.left + monitors[1].rect.right) / 2, - (monitors[1].rect.top + monitors[1].rect.bottom) / 2}; - - int xDiff = abs(center0.x - center1.x); - int yDiff = abs(center0.y - center1.y); - - bool isHorizontal = xDiff > yDiff; - -#ifdef _DEBUG - Logger::info(L"CursorWrap DEBUG: Monitor centers: M0=({}, {}), M1=({}, {})", - center0.x, center0.y, center1.x, center1.y); - Logger::info(L"CursorWrap DEBUG: Differences: X={}, Y={}, IsHorizontal={}", - xDiff, yDiff, isHorizontal); -#endif - - if (isHorizontal) - { - // Horizontal arrangement - place in middle row [1,0] and [1,2] - for (const auto& monitor : monitors) - { - HMONITOR hMonitor = MonitorFromRect(&monitor.rect, MONITOR_DEFAULTTONEAREST); - POINT center = {(monitor.rect.left + monitor.rect.right) / 2, - (monitor.rect.top + monitor.rect.bottom) / 2}; - - int row = 1; // Middle row - int col = (center.x < (center0.x + center1.x) / 2) ? 0 : 2; // Left or right based on center - - grid[row][col] = hMonitor; - monitorToPosition[hMonitor] = {row, col, true}; - positionToMonitor[{row, col}] = hMonitor; - -#ifdef _DEBUG - Logger::info(L"CursorWrap DEBUG: Monitor {} (horizontal) placed at grid[{}][{}]", - monitor.monitorId, row, col); -#endif - } - } - else - { - // *** VERTICAL ARRANGEMENT - CRITICAL LOGIC *** - // Sort monitors by Y coordinate to determine vertical order - std::vector> sortedMonitors; - for (int i = 0; i < 2; i++) { - sortedMonitors.push_back({i, monitors[i]}); - } - - // Sort by Y coordinate (top to bottom) - std::sort(sortedMonitors.begin(), sortedMonitors.end(), - [](const std::pair& a, const std::pair& b) { - int centerA = (a.second.rect.top + a.second.rect.bottom) / 2; - int centerB = (b.second.rect.top + b.second.rect.bottom) / 2; - return centerA < centerB; // Top first - }); - -#ifdef _DEBUG - Logger::info(L"CursorWrap DEBUG: VERTICAL ARRANGEMENT DETECTED"); - Logger::info(L"CursorWrap DEBUG: Top monitor: ID={}, Y-center={}", - sortedMonitors[0].second.monitorId, - (sortedMonitors[0].second.rect.top + sortedMonitors[0].second.rect.bottom) / 2); - Logger::info(L"CursorWrap DEBUG: Bottom monitor: ID={}, Y-center={}", - sortedMonitors[1].second.monitorId, - (sortedMonitors[1].second.rect.top + sortedMonitors[1].second.rect.bottom) / 2); -#endif - - // Place monitors in grid based on sorted order - for (int i = 0; i < 2; i++) { - const auto& monitorPair = sortedMonitors[i]; - const auto& monitor = monitorPair.second; - HMONITOR hMonitor = MonitorFromRect(&monitor.rect, MONITOR_DEFAULTTONEAREST); - - int col = 1; // Middle column for vertical arrangement - int row = (i == 0) ? 0 : 2; // Top monitor at row 0, bottom at row 2 - - grid[row][col] = hMonitor; - monitorToPosition[hMonitor] = {row, col, true}; - positionToMonitor[{row, col}] = hMonitor; - -#ifdef _DEBUG - Logger::info(L"CursorWrap DEBUG: Monitor {} (vertical) placed at grid[{}][{}] - {} position", - monitor.monitorId, row, col, (i == 0) ? L"TOP" : L"BOTTOM"); -#endif - } - } - } - else - { - // For more than 2 monitors, use edge-based alignment algorithm - // This ensures monitors with aligned edges (e.g., top edges at same Y) are grouped in same row - - // Helper lambda to check if two ranges overlap or are adjacent (with tolerance) - auto rangesOverlapOrTouch = [](int start1, int end1, int start2, int end2, int tolerance = 50) -> bool { - // Check if ranges overlap or are within tolerance distance - return (start1 <= end2 + tolerance) && (start2 <= end1 + tolerance); - }; - - // Sort monitors by horizontal position (left edge) for column assignment - std::vector monitorsByX; - for (const auto& monitor : monitors) { - monitorsByX.push_back(&monitor); - } - std::sort(monitorsByX.begin(), monitorsByX.end(), [](const MonitorInfo* a, const MonitorInfo* b) { - return a->rect.left < b->rect.left; - }); - - // Sort monitors by vertical position (top edge) for row assignment - std::vector monitorsByY; - for (const auto& monitor : monitors) { - monitorsByY.push_back(&monitor); - } - std::sort(monitorsByY.begin(), monitorsByY.end(), [](const MonitorInfo* a, const MonitorInfo* b) { - return a->rect.top < b->rect.top; - }); - - // Assign rows based on vertical overlap - monitors that overlap vertically should be in same row - std::map monitorToRow; - int currentRow = 0; - - for (size_t i = 0; i < monitorsByY.size(); i++) { - const auto* monitor = monitorsByY[i]; - - // Check if this monitor overlaps vertically with any monitor already assigned to current row - bool foundOverlap = false; - for (size_t j = 0; j < i; j++) { - const auto* other = monitorsByY[j]; - if (monitorToRow[other] == currentRow) { - // Check vertical overlap - if (rangesOverlapOrTouch(monitor->rect.top, monitor->rect.bottom, - other->rect.top, other->rect.bottom)) { - monitorToRow[monitor] = currentRow; - foundOverlap = true; - break; - } - } - } - - if (!foundOverlap) { - // Start new row if no overlap found and we have room - if (currentRow < 2 && i < monitorsByY.size() - 1) { - currentRow++; - } - monitorToRow[monitor] = currentRow; - } - } - - // Assign columns based on horizontal position (left-to-right order) - // Monitors are already sorted by X coordinate (left edge) - std::map monitorToCol; - - // For horizontal arrangement, distribute monitors evenly across columns - if (monitorsByX.size() == 1) { - // Single monitor - place in middle column - monitorToCol[monitorsByX[0]] = 1; - } - else if (monitorsByX.size() == 2) { - // Two monitors - place at opposite ends for wrapping - monitorToCol[monitorsByX[0]] = 0; // Leftmost monitor - monitorToCol[monitorsByX[1]] = 2; // Rightmost monitor - } - else { - // Three or more monitors - distribute across grid - for (size_t i = 0; i < monitorsByX.size() && i < 3; i++) { - monitorToCol[monitorsByX[i]] = static_cast(i); - } - // If more than 3 monitors, place extras in rightmost column - for (size_t i = 3; i < monitorsByX.size(); i++) { - monitorToCol[monitorsByX[i]] = 2; - } - } - - // Place monitors in grid using the computed row/column assignments - for (const auto& monitor : monitors) - { - HMONITOR hMonitor = MonitorFromRect(&monitor.rect, MONITOR_DEFAULTTONEAREST); - int row = monitorToRow[&monitor]; - int col = monitorToCol[&monitor]; - - grid[row][col] = hMonitor; - monitorToPosition[hMonitor] = {row, col, true}; - positionToMonitor[{row, col}] = hMonitor; - -#ifdef _DEBUG - Logger::info(L"CursorWrap DEBUG: Monitor {} placed at grid[{}][{}] (left={}, top={}, right={}, bottom={})", - monitor.monitorId, row, col, - monitor.rect.left, monitor.rect.top, monitor.rect.right, monitor.rect.bottom); -#endif - } - } - -#ifdef _DEBUG - // *** CRITICAL: Print topology map using OutputDebugString for debug builds *** - Logger::info(L"CursorWrap DEBUG: ======= FINAL TOPOLOGY MAP ======="); - OutputDebugStringA("CursorWrap TOPOLOGY MAP:\n"); - for (int r = 0; r < 3; r++) - { - std::string rowStr = " "; - for (int c = 0; c < 3; c++) - { - if (grid[r][c]) - { - // Find monitor ID for this handle - int monitorId = -1; - for (const auto& monitor : monitors) - { - HMONITOR handle = MonitorFromRect(&monitor.rect, MONITOR_DEFAULTTONEAREST); - if (handle == grid[r][c]) - { - monitorId = monitor.monitorId + 1; // Convert to 1-based for display - break; - } - } - rowStr += std::to_string(monitorId) + " "; - } - else - { - rowStr += ". "; - } - } - rowStr += "\n"; - OutputDebugStringA(rowStr.c_str()); - - // Also log to PowerToys logger - std::wstring wRowStr(rowStr.begin(), rowStr.end()); - Logger::info(wRowStr.c_str()); - } - OutputDebugStringA("======= END TOPOLOGY MAP =======\n"); - - // Additional validation logging - Logger::info(L"CursorWrap DEBUG: ======= GRID POSITION VALIDATION ======="); - for (const auto& monitor : monitors) - { - HMONITOR hMonitor = MonitorFromRect(&monitor.rect, MONITOR_DEFAULTTONEAREST); - LogicalPosition pos = GetPosition(hMonitor); - if (pos.isValid) - { - Logger::info(L"CursorWrap DEBUG: Monitor {} -> grid[{}][{}]", monitor.monitorId, pos.row, pos.col); - OutputDebugStringA(("Monitor " + std::to_string(monitor.monitorId) + " -> grid[" + std::to_string(pos.row) + "][" + std::to_string(pos.col) + "]\n").c_str()); - - // Test adjacent finding - HMONITOR up = FindAdjacentMonitor(hMonitor, -1, 0); - HMONITOR down = FindAdjacentMonitor(hMonitor, 1, 0); - HMONITOR left = FindAdjacentMonitor(hMonitor, 0, -1); - HMONITOR right = FindAdjacentMonitor(hMonitor, 0, 1); - - Logger::info(L"CursorWrap DEBUG: Monitor {} adjacents - Up: {}, Down: {}, Left: {}, Right: {}", - monitor.monitorId, up ? L"YES" : L"NO", down ? L"YES" : L"NO", - left ? L"YES" : L"NO", right ? L"YES" : L"NO"); - } - } - Logger::info(L"CursorWrap DEBUG: ======= TOPOLOGY INITIALIZATION COMPLETE ======="); -#endif -} - -LogicalPosition MonitorTopology::GetPosition(HMONITOR monitor) const -{ - auto it = monitorToPosition.find(monitor); - if (it != monitorToPosition.end()) - { - return it->second; - } - return {-1, -1, false}; -} - -HMONITOR MonitorTopology::GetMonitorAt(int row, int col) const -{ - if (row >= 0 && row < 3 && col >= 0 && col < 3) - { - return grid[row][col]; - } - return nullptr; -} - -HMONITOR MonitorTopology::FindAdjacentMonitor(HMONITOR current, int deltaRow, int deltaCol) const -{ - LogicalPosition currentPos = GetPosition(current); - if (!currentPos.isValid) return nullptr; - - int newRow = currentPos.row + deltaRow; - int newCol = currentPos.col + deltaCol; - - return GetMonitorAt(newRow, newCol); -} - extern "C" __declspec(dllexport) PowertoyModuleIface* __cdecl powertoy_create() { return new CursorWrap(); diff --git a/src/modules/MouseUtils/FindMyMouse/FindMyMouse.vcxproj b/src/modules/MouseUtils/FindMyMouse/FindMyMouse.vcxproj index 8e606b88ad..47867ebb2d 100644 --- a/src/modules/MouseUtils/FindMyMouse/FindMyMouse.vcxproj +++ b/src/modules/MouseUtils/FindMyMouse/FindMyMouse.vcxproj @@ -21,7 +21,7 @@ false false false - true + false false @@ -33,13 +33,13 @@ DynamicLibrary true - v143 + Unicode DynamicLibrary false - v143 + true Unicode diff --git a/src/modules/MouseUtils/MouseHighlighter/MouseHighlighter.vcxproj b/src/modules/MouseUtils/MouseHighlighter/MouseHighlighter.vcxproj index df0df021da..4463846c20 100644 --- a/src/modules/MouseUtils/MouseHighlighter/MouseHighlighter.vcxproj +++ b/src/modules/MouseUtils/MouseHighlighter/MouseHighlighter.vcxproj @@ -12,13 +12,13 @@ DynamicLibrary true - v143 + Unicode DynamicLibrary false - v143 + true Unicode diff --git a/src/modules/MouseUtils/MouseJump.Common.UnitTests/Helpers/DrawingHelperTests.cs b/src/modules/MouseUtils/MouseJump.Common.UnitTests/Helpers/DrawingHelperTests.cs index f6c6c51831..63f372f20b 100644 --- a/src/modules/MouseUtils/MouseJump.Common.UnitTests/Helpers/DrawingHelperTests.cs +++ b/src/modules/MouseUtils/MouseJump.Common.UnitTests/Helpers/DrawingHelperTests.cs @@ -143,11 +143,12 @@ public static class DrawingHelperTests var actualPixel = actual.GetPixel(x, y); // allow a small tolerance for rounding differences in gdi + // using a tolerance of 3 for support of minor differences in Windows Server 2025 CI Assert.IsTrue( - (Math.Abs(expectedPixel.A - actualPixel.A) <= 1) && - (Math.Abs(expectedPixel.R - actualPixel.R) <= 1) && - (Math.Abs(expectedPixel.G - actualPixel.G) <= 1) && - (Math.Abs(expectedPixel.B - actualPixel.B) <= 1), + (Math.Abs(expectedPixel.A - actualPixel.A) <= 3) && + (Math.Abs(expectedPixel.R - actualPixel.R) <= 3) && + (Math.Abs(expectedPixel.G - actualPixel.G) <= 3) && + (Math.Abs(expectedPixel.B - actualPixel.B) <= 3), $"images differ at pixel ({x}, {y}) - expected: {expectedPixel}, actual: {actualPixel}"); } } diff --git a/src/modules/MouseUtils/MouseJump/MouseJump.vcxproj b/src/modules/MouseUtils/MouseJump/MouseJump.vcxproj index 29e8f444bf..b6ddd885fb 100644 --- a/src/modules/MouseUtils/MouseJump/MouseJump.vcxproj +++ b/src/modules/MouseUtils/MouseJump/MouseJump.vcxproj @@ -12,13 +12,13 @@ DynamicLibrary true - v143 + Unicode DynamicLibrary false - v143 + true Unicode diff --git a/src/modules/MouseUtils/MousePointerCrosshairs/MousePointerCrosshairs.vcxproj b/src/modules/MouseUtils/MousePointerCrosshairs/MousePointerCrosshairs.vcxproj index 58668c663f..0823564a44 100644 --- a/src/modules/MouseUtils/MousePointerCrosshairs/MousePointerCrosshairs.vcxproj +++ b/src/modules/MouseUtils/MousePointerCrosshairs/MousePointerCrosshairs.vcxproj @@ -13,13 +13,13 @@ DynamicLibrary true - v143 + Unicode DynamicLibrary false - v143 + true Unicode diff --git a/src/modules/MouseWithoutBorders/ModuleInterface/MouseWithoutBordersModuleInterface.vcxproj b/src/modules/MouseWithoutBorders/ModuleInterface/MouseWithoutBordersModuleInterface.vcxproj index 8f04969947..f2af1e923e 100644 --- a/src/modules/MouseWithoutBorders/ModuleInterface/MouseWithoutBordersModuleInterface.vcxproj +++ b/src/modules/MouseWithoutBorders/ModuleInterface/MouseWithoutBordersModuleInterface.vcxproj @@ -12,7 +12,7 @@ DynamicLibrary - v143 + diff --git a/src/modules/NewPlus/NewShellExtensionContextMenu.win10/NewPlus.ShellExtension.win10.vcxproj b/src/modules/NewPlus/NewShellExtensionContextMenu.win10/NewPlus.ShellExtension.win10.vcxproj index ea6f5ab7ba..fb1bf3c06b 100644 --- a/src/modules/NewPlus/NewShellExtensionContextMenu.win10/NewPlus.ShellExtension.win10.vcxproj +++ b/src/modules/NewPlus/NewShellExtensionContextMenu.win10/NewPlus.ShellExtension.win10.vcxproj @@ -14,7 +14,7 @@ DynamicLibrary - v143 + Unicode diff --git a/src/modules/NewPlus/NewShellExtensionContextMenu/NewShellExtensionContextMenu.vcxproj b/src/modules/NewPlus/NewShellExtensionContextMenu/NewShellExtensionContextMenu.vcxproj index 7dade586e7..e43fb73e80 100644 --- a/src/modules/NewPlus/NewShellExtensionContextMenu/NewShellExtensionContextMenu.vcxproj +++ b/src/modules/NewPlus/NewShellExtensionContextMenu/NewShellExtensionContextMenu.vcxproj @@ -16,13 +16,13 @@ DynamicLibrary true - v143 + Unicode DynamicLibrary false - v143 + true Unicode diff --git a/src/modules/PowerOCR/PowerOCRModuleInterface/PowerOCRModuleInterface.vcxproj b/src/modules/PowerOCR/PowerOCRModuleInterface/PowerOCRModuleInterface.vcxproj index 53b1dd8336..252037b584 100644 --- a/src/modules/PowerOCR/PowerOCRModuleInterface/PowerOCRModuleInterface.vcxproj +++ b/src/modules/PowerOCR/PowerOCRModuleInterface/PowerOCRModuleInterface.vcxproj @@ -15,7 +15,7 @@ DynamicLibrary - v143 + diff --git a/src/modules/ShortcutGuide/ShortcutGuide/ShortcutGuide.vcxproj b/src/modules/ShortcutGuide/ShortcutGuide/ShortcutGuide.vcxproj index 045be94f2b..95dcaebc1c 100644 --- a/src/modules/ShortcutGuide/ShortcutGuide/ShortcutGuide.vcxproj +++ b/src/modules/ShortcutGuide/ShortcutGuide/ShortcutGuide.vcxproj @@ -17,7 +17,7 @@ Application - v143 + v141 v142 Unicode diff --git a/src/modules/ShortcutGuide/ShortcutGuideModuleInterface/ShortcutGuideModuleInterface.vcxproj b/src/modules/ShortcutGuide/ShortcutGuideModuleInterface/ShortcutGuideModuleInterface.vcxproj index be903fbe3e..c5590605ae 100644 --- a/src/modules/ShortcutGuide/ShortcutGuideModuleInterface/ShortcutGuideModuleInterface.vcxproj +++ b/src/modules/ShortcutGuide/ShortcutGuideModuleInterface/ShortcutGuideModuleInterface.vcxproj @@ -15,14 +15,14 @@ DynamicLibrary true - v143 + Unicode Spectre DynamicLibrary false - v143 + true Unicode Spectre diff --git a/src/modules/Workspaces/WorkspacesEditor/OverlayWindow.xaml.cs b/src/modules/Workspaces/WorkspacesEditor/OverlayWindow.xaml.cs index 54e892d9bd..d1646e7282 100644 --- a/src/modules/Workspaces/WorkspacesEditor/OverlayWindow.xaml.cs +++ b/src/modules/Workspaces/WorkspacesEditor/OverlayWindow.xaml.cs @@ -2,8 +2,11 @@ // 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.Windows; +using WorkspacesEditor.Utils; + namespace WorkspacesEditor { /// @@ -11,9 +14,40 @@ namespace WorkspacesEditor /// public partial class OverlayWindow : Window { + private int _targetX; + private int _targetY; + private int _targetWidth; + private int _targetHeight; + public OverlayWindow() { InitializeComponent(); + SourceInitialized += OnWindowSourceInitialized; + } + + /// + /// Sets the target bounds for the overlay window. + /// The window will be positioned using DPI-unaware context after initialization. + /// + public void SetTargetBounds(int x, int y, int width, int height) + { + _targetX = x; + _targetY = y; + _targetWidth = width; + _targetHeight = height; + + // Set initial WPF properties (will be corrected after HWND creation) + Left = x; + Top = y; + Width = width; + Height = height; + } + + private void OnWindowSourceInitialized(object sender, EventArgs e) + { + // Reposition window using DPI-unaware context to match the virtual coordinates. + // This fixes overlay positioning on mixed-DPI multi-monitor setups. + NativeMethods.SetWindowPositionDpiUnaware(this, _targetX, _targetY, _targetWidth, _targetHeight); } } } diff --git a/src/modules/Workspaces/WorkspacesEditor/Utils/NativeMethods.cs b/src/modules/Workspaces/WorkspacesEditor/Utils/NativeMethods.cs index 4105cbe959..9687aeac63 100644 --- a/src/modules/Workspaces/WorkspacesEditor/Utils/NativeMethods.cs +++ b/src/modules/Workspaces/WorkspacesEditor/Utils/NativeMethods.cs @@ -4,6 +4,8 @@ using System; using System.Runtime.InteropServices; +using System.Windows; +using System.Windows.Interop; namespace WorkspacesEditor.Utils { @@ -17,6 +19,39 @@ namespace WorkspacesEditor.Utils [return: MarshalAs(UnmanagedType.Bool)] public static extern bool MoveWindow(IntPtr hWnd, int X, int Y, int nWidth, int nHeight, bool bRepaint); + [DllImport("user32.dll", SetLastError = true)] + private static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int X, int Y, int cx, int cy, uint uFlags); + + [DllImport("user32.dll")] + private static extern IntPtr SetThreadDpiAwarenessContext(IntPtr dpiContext); + + private const uint SWP_NOZORDER = 0x0004; + private const uint SWP_NOACTIVATE = 0x0010; + + private static readonly IntPtr DPI_AWARENESS_CONTEXT_UNAWARE = new IntPtr(-1); + + /// + /// Positions a WPF window using DPI-unaware context to match the virtual coordinates. + /// This fixes overlay positioning on mixed-DPI multi-monitor setups. + /// + public static void SetWindowPositionDpiUnaware(Window window, int x, int y, int width, int height) + { + var helper = new WindowInteropHelper(window).Handle; + if (helper != IntPtr.Zero) + { + // Temporarily switch to DPI-unaware context to position window. + IntPtr oldContext = SetThreadDpiAwarenessContext(DPI_AWARENESS_CONTEXT_UNAWARE); + try + { + SetWindowPos(helper, IntPtr.Zero, x, y, width, height, SWP_NOZORDER | SWP_NOACTIVATE); + } + finally + { + SetThreadDpiAwarenessContext(oldContext); + } + } + } + [DllImport("USER32.DLL")] public static extern bool SetForegroundWindow(IntPtr hWnd); diff --git a/src/modules/Workspaces/WorkspacesEditor/ViewModels/MainViewModel.cs b/src/modules/Workspaces/WorkspacesEditor/ViewModels/MainViewModel.cs index 9c76c26fa0..5741fd65ab 100644 --- a/src/modules/Workspaces/WorkspacesEditor/ViewModels/MainViewModel.cs +++ b/src/modules/Workspaces/WorkspacesEditor/ViewModels/MainViewModel.cs @@ -495,10 +495,10 @@ namespace WorkspacesEditor.ViewModels { var bounds = screen.Bounds; OverlayWindow overlayWindow = new OverlayWindow(); - overlayWindow.Top = bounds.Top; - overlayWindow.Left = bounds.Left; - overlayWindow.Width = bounds.Width; - overlayWindow.Height = bounds.Height; + + // Use DPI-unaware positioning to fix overlay on mixed-DPI multi-monitor setups + overlayWindow.SetTargetBounds(bounds.Left, bounds.Top, bounds.Width, bounds.Height); + overlayWindow.ShowActivated = true; overlayWindow.Topmost = true; overlayWindow.Show(); diff --git a/src/modules/Workspaces/WorkspacesLauncher/WorkspacesLauncher.vcxproj b/src/modules/Workspaces/WorkspacesLauncher/WorkspacesLauncher.vcxproj index 9d4fc4bcab..8faf362704 100644 --- a/src/modules/Workspaces/WorkspacesLauncher/WorkspacesLauncher.vcxproj +++ b/src/modules/Workspaces/WorkspacesLauncher/WorkspacesLauncher.vcxproj @@ -60,7 +60,7 @@ Application - v143 + Unicode Spectre diff --git a/src/modules/Workspaces/WorkspacesLib.UnitTests/WorkspacesLibUnitTests.vcxproj b/src/modules/Workspaces/WorkspacesLib.UnitTests/WorkspacesLibUnitTests.vcxproj index 14f87ef729..1c7098f3b3 100644 --- a/src/modules/Workspaces/WorkspacesLib.UnitTests/WorkspacesLibUnitTests.vcxproj +++ b/src/modules/Workspaces/WorkspacesLib.UnitTests/WorkspacesLibUnitTests.vcxproj @@ -9,7 +9,7 @@ - v143 + DynamicLibrary diff --git a/src/modules/Workspaces/WorkspacesLib/WorkspacesLib.vcxproj b/src/modules/Workspaces/WorkspacesLib/WorkspacesLib.vcxproj index a7e62192cf..e968036d59 100644 --- a/src/modules/Workspaces/WorkspacesLib/WorkspacesLib.vcxproj +++ b/src/modules/Workspaces/WorkspacesLib/WorkspacesLib.vcxproj @@ -11,7 +11,7 @@ StaticLibrary - v143 + diff --git a/src/modules/Workspaces/WorkspacesModuleInterface/WorkspacesModuleInterface.vcxproj b/src/modules/Workspaces/WorkspacesModuleInterface/WorkspacesModuleInterface.vcxproj index 28ee035180..40d400daf4 100644 --- a/src/modules/Workspaces/WorkspacesModuleInterface/WorkspacesModuleInterface.vcxproj +++ b/src/modules/Workspaces/WorkspacesModuleInterface/WorkspacesModuleInterface.vcxproj @@ -13,7 +13,7 @@ DynamicLibrary - v143 + diff --git a/src/modules/Workspaces/WorkspacesSnapshotTool/WorkspacesSnapshotTool.vcxproj b/src/modules/Workspaces/WorkspacesSnapshotTool/WorkspacesSnapshotTool.vcxproj index 05e4241c1c..00f0633f11 100644 --- a/src/modules/Workspaces/WorkspacesSnapshotTool/WorkspacesSnapshotTool.vcxproj +++ b/src/modules/Workspaces/WorkspacesSnapshotTool/WorkspacesSnapshotTool.vcxproj @@ -60,7 +60,7 @@ Application - v143 + Unicode Spectre diff --git a/src/modules/Workspaces/WorkspacesWindowArranger/WorkspacesWindowArranger.vcxproj b/src/modules/Workspaces/WorkspacesWindowArranger/WorkspacesWindowArranger.vcxproj index 2451be2470..85d2c021ba 100644 --- a/src/modules/Workspaces/WorkspacesWindowArranger/WorkspacesWindowArranger.vcxproj +++ b/src/modules/Workspaces/WorkspacesWindowArranger/WorkspacesWindowArranger.vcxproj @@ -60,7 +60,7 @@ Application - v143 + Unicode Spectre diff --git a/src/modules/ZoomIt/ZoomIt/AudioSampleGenerator.cpp b/src/modules/ZoomIt/ZoomIt/AudioSampleGenerator.cpp index 21e9883bb7..b3b0ed373f 100644 --- a/src/modules/ZoomIt/ZoomIt/AudioSampleGenerator.cpp +++ b/src/modules/ZoomIt/ZoomIt/AudioSampleGenerator.cpp @@ -1,9 +1,24 @@ #include "pch.h" #include "AudioSampleGenerator.h" #include "CaptureFrameWait.h" +#include "LoopbackCapture.h" +#include extern TCHAR g_MicrophoneDeviceId[]; +namespace +{ + // Declare the IMemoryBufferByteAccess interface for accessing raw buffer data + MIDL_INTERFACE("5b0d3235-4dba-4d44-8657-1f1d0f83e9a3") + IMemoryBufferByteAccess : public IUnknown + { + public: + virtual HRESULT STDMETHODCALLTYPE GetBuffer( + BYTE** value, + UINT32* capacity) = 0; + }; +} + namespace winrt { using namespace Windows::Foundation; @@ -19,17 +34,23 @@ namespace winrt using namespace Windows::Devices::Enumeration; } -AudioSampleGenerator::AudioSampleGenerator() +AudioSampleGenerator::AudioSampleGenerator(bool captureMicrophone, bool captureSystemAudio) + : m_captureMicrophone(captureMicrophone) + , m_captureSystemAudio(captureSystemAudio) { + OutputDebugStringA(("AudioSampleGenerator created, captureMicrophone=" + + std::string(captureMicrophone ? "true" : "false") + + ", captureSystemAudio=" + std::string(captureSystemAudio ? "true" : "false") + "\n").c_str()); m_audioEvent.create(wil::EventOptions::ManualReset); m_endEvent.create(wil::EventOptions::ManualReset); + m_startEvent.create(wil::EventOptions::ManualReset); m_asyncInitialized.create(wil::EventOptions::ManualReset); } AudioSampleGenerator::~AudioSampleGenerator() { Stop(); - if (m_started.load()) + if (m_audioGraph) { m_audioGraph.Close(); } @@ -40,6 +61,10 @@ winrt::IAsyncAction AudioSampleGenerator::InitializeAsync() auto expected = false; if (m_initialized.compare_exchange_strong(expected, true)) { + // Reset state in case this instance is reused. + m_endEvent.ResetEvent(); + m_startEvent.ResetEvent(); + // Initialize the audio graph auto audioGraphSettings = winrt::AudioGraphSettings(winrt::AudioRenderCategory::Media); auto audioGraphResult = co_await winrt::AudioGraph::CreateAsync(audioGraphSettings); @@ -49,28 +74,88 @@ winrt::IAsyncAction AudioSampleGenerator::InitializeAsync() } m_audioGraph = audioGraphResult.Graph(); - // Initialize the selected microphone - auto defaultMicrophoneId = winrt::MediaDevice::GetDefaultAudioCaptureId(winrt::AudioDeviceRole::Default); - auto microphoneId = (g_MicrophoneDeviceId[0] == 0) ? defaultMicrophoneId : winrt::to_hstring(g_MicrophoneDeviceId); - auto microphone = co_await winrt::DeviceInformation::CreateFromIdAsync(microphoneId); + // Get AudioGraph encoding properties for resampling + auto graphProps = m_audioGraph.EncodingProperties(); + m_graphSampleRate = graphProps.SampleRate(); + m_graphChannels = graphProps.ChannelCount(); - // Initialize audio input and output nodes - auto inputNodeResult = co_await m_audioGraph.CreateDeviceInputNodeAsync(winrt::MediaCategory::Media, m_audioGraph.EncodingProperties(), microphone); - if (inputNodeResult.Status() != winrt::AudioDeviceNodeCreationStatus::Success && microphoneId != defaultMicrophoneId) - { - // If the selected microphone failed, try again with the default - microphone = co_await winrt::DeviceInformation::CreateFromIdAsync(defaultMicrophoneId); - inputNodeResult = co_await m_audioGraph.CreateDeviceInputNodeAsync(winrt::MediaCategory::Media, m_audioGraph.EncodingProperties(), microphone); - } - if (inputNodeResult.Status() != winrt::AudioDeviceNodeCreationStatus::Success) - { - throw winrt::hresult_error(E_FAIL, L"Failed to initialize input audio node!"); - } - m_audioInputNode = inputNodeResult.DeviceInputNode(); + OutputDebugStringA(("AudioGraph initialized: " + std::to_string(m_graphSampleRate) + + " Hz, " + std::to_string(m_graphChannels) + " ch\n").c_str()); + + // Create submix node to mix microphone and loopback audio + m_submixNode = m_audioGraph.CreateSubmixNode(); m_audioOutputNode = m_audioGraph.CreateFrameOutputNode(); + m_submixNode.AddOutgoingConnection(m_audioOutputNode); + + // Initialize WASAPI loopback capture for system audio (if enabled) + if (m_captureSystemAudio) + { + m_loopbackCapture = std::make_unique(); + } + if (m_loopbackCapture && SUCCEEDED(m_loopbackCapture->Initialize())) + { + auto loopbackFormat = m_loopbackCapture->GetFormat(); + if (loopbackFormat) + { + m_loopbackChannels = loopbackFormat->nChannels; + m_loopbackSampleRate = loopbackFormat->nSamplesPerSec; + m_resampleRatio = static_cast(m_loopbackSampleRate) / static_cast(m_graphSampleRate); + + OutputDebugStringA(("Loopback initialized: " + std::to_string(m_loopbackSampleRate) + + " Hz, " + std::to_string(m_loopbackChannels) + " ch, resample ratio=" + + std::to_string(m_resampleRatio) + "\n").c_str()); + } + } + else if (m_captureSystemAudio) + { + OutputDebugStringA("WARNING: Failed to initialize loopback capture\n"); + m_loopbackCapture.reset(); + } + + // Always initialize a microphone input node to keep the AudioGraph running at real-time pace. + // When mic capture is disabled, we mute it so only loopback audio is captured. + { + auto defaultMicrophoneId = winrt::MediaDevice::GetDefaultAudioCaptureId(winrt::AudioDeviceRole::Default); + auto microphoneId = (m_captureMicrophone && g_MicrophoneDeviceId[0] != 0) + ? winrt::to_hstring(g_MicrophoneDeviceId) + : defaultMicrophoneId; + if (!microphoneId.empty()) + { + auto microphone = co_await winrt::DeviceInformation::CreateFromIdAsync(microphoneId); + + // Initialize audio input node + auto inputNodeResult = co_await m_audioGraph.CreateDeviceInputNodeAsync(winrt::MediaCategory::Media, m_audioGraph.EncodingProperties(), microphone); + if (inputNodeResult.Status() != winrt::AudioDeviceNodeCreationStatus::Success && microphoneId != defaultMicrophoneId) + { + // If the selected microphone failed, try again with the default + microphone = co_await winrt::DeviceInformation::CreateFromIdAsync(defaultMicrophoneId); + inputNodeResult = co_await m_audioGraph.CreateDeviceInputNodeAsync(winrt::MediaCategory::Media, m_audioGraph.EncodingProperties(), microphone); + } + if (inputNodeResult.Status() == winrt::AudioDeviceNodeCreationStatus::Success) + { + m_audioInputNode = inputNodeResult.DeviceInputNode(); + m_audioInputNode.AddOutgoingConnection(m_submixNode); + + // If mic capture is disabled, mute the input so only loopback is captured + if (!m_captureMicrophone) + { + m_audioInputNode.OutgoingGain(0.0); + OutputDebugStringA("Mic input created but muted (loopback-only mode)\n"); + } + else + { + OutputDebugStringA("Mic input created and active\n"); + } + } + } + } + + // Loopback capture is only required when system audio capture is enabled + if (m_captureSystemAudio && !m_loopbackCapture) + { + throw winrt::hresult_error(E_FAIL, L"Failed to initialize loopback audio capture!"); + } - // Hookup audio nodes - m_audioInputNode.AddOutgoingConnection(m_audioOutputNode); m_audioGraph.QuantumStarted({ this, &AudioSampleGenerator::OnAudioQuantumStarted }); m_asyncInitialized.SetEvent(); @@ -86,7 +171,37 @@ winrt::AudioEncodingProperties AudioSampleGenerator::GetEncodingProperties() std::optional AudioSampleGenerator::TryGetNextSample() { CheckInitialized(); - CheckStarted(); + + // The MediaStreamSource can request audio samples before we've started the audio graph. + // Instead of throwing (which crashes the app), wait until either Start() is called + // or Stop() signals end-of-stream. + if (!m_started.load()) + { + std::vector events = { m_endEvent.get(), m_startEvent.get() }; + auto waitResult = WaitForMultipleObjectsEx(static_cast(events.size()), events.data(), false, INFINITE, false); + auto eventIndex = -1; + switch (waitResult) + { + case WAIT_OBJECT_0: + case WAIT_OBJECT_0 + 1: + eventIndex = waitResult - WAIT_OBJECT_0; + break; + } + WINRT_VERIFY(eventIndex >= 0); + + if (events[eventIndex] == m_endEvent.get()) + { + // End event signaled, but check if there are any remaining samples in the queue + auto lock = m_lock.lock_exclusive(); + if (!m_samples.empty()) + { + std::optional result(m_samples.front()); + m_samples.pop_front(); + return result; + } + return std::nullopt; + } + } { auto lock = m_lock.lock_exclusive(); @@ -118,11 +233,25 @@ std::optional AudioSampleGenerator::TryGetNextSample() auto signaledEvent = events[eventIndex]; if (signaledEvent == m_endEvent.get()) { + // End was signaled, but check for any remaining samples before returning nullopt + auto lock = m_lock.lock_exclusive(); + if (!m_samples.empty()) + { + std::optional result(m_samples.front()); + m_samples.pop_front(); + return result; + } return std::nullopt; } else { auto lock = m_lock.lock_exclusive(); + if (m_samples.empty()) + { + // Spurious wake or race - no samples available + // If end is signaled, return nullopt + return m_endEvent.is_signaled() ? std::nullopt : std::optional{}; + } std::optional result(m_samples.front()); m_samples.pop_front(); return result; @@ -135,23 +264,357 @@ void AudioSampleGenerator::Start() auto expected = false; if (m_started.compare_exchange_strong(expected, true)) { + m_endEvent.ResetEvent(); + m_startEvent.SetEvent(); + + // Start loopback capture if available + if (m_loopbackCapture) + { + // Clear any stale samples + { + auto lock = m_loopbackBufferLock.lock_exclusive(); + m_loopbackBuffer.clear(); + } + + m_resampleInputBuffer.clear(); + m_resampleInputPos = 0.0; + + m_loopbackCapture->Start(); + } + m_audioGraph.Start(); } } void AudioSampleGenerator::Stop() { - CheckInitialized(); - if (m_started.load()) + // Stop may be called during teardown even if initialization hasn't completed. + // It must never throw. + + if (!m_initialized.load()) { - m_asyncInitialized.wait(); - m_audioGraph.Stop(); m_endEvent.SetEvent(); + return; } + + m_asyncInitialized.wait(); + + // Stop loopback capture first + if (m_loopbackCapture) + { + m_loopbackCapture->Stop(); + } + + // Flush any remaining samples from the loopback capture before stopping the audio graph + FlushRemainingAudio(); + + // Stop the audio graph - no more quantum callbacks will run + m_audioGraph.Stop(); + + // Close the microphone input node to release the device so Windows no longer + // reports the microphone as in use by ZoomIt. + if (m_audioInputNode) + { + m_audioInputNode.Close(); + m_audioInputNode = nullptr; + } + + // Mark as stopped + m_started.store(false); + + // Combine all remaining queued samples into one final sample so it can be + // returned immediately without waiting for additional TryGetNextSample calls + CombineQueuedSamples(); + + // NOW signal end event - this allows TryGetNextSample to return remaining + // queued samples and then return nullopt + m_endEvent.SetEvent(); + m_audioEvent.SetEvent(); // Also wake any waiting TryGetNextSample + + // DO NOT clear m_loopbackBuffer or m_samples here - allow MediaTranscoder to + // consume remaining queued audio samples to avoid audio cutoff at end of recording. + // TryGetNextSample() will return nullopt once m_samples is empty and + // m_endEvent is signaled. Buffers will be cleaned up on destruction. +} + +void AudioSampleGenerator::AppendResampledLoopbackSamples(std::vector const& rawLoopbackSamples, bool flushRemaining) +{ + if (rawLoopbackSamples.empty()) + { + return; + } + + m_resampleInputBuffer.insert(m_resampleInputBuffer.end(), rawLoopbackSamples.begin(), rawLoopbackSamples.end()); + + if (m_loopbackChannels == 0 || m_graphChannels == 0 || m_resampleRatio <= 0.0) + { + return; + } + + std::vector resampledSamples; + while (true) + { + const uint32_t inputFrames = static_cast(m_resampleInputBuffer.size() / m_loopbackChannels); + if (inputFrames == 0) + { + break; + } + + if (!flushRemaining) + { + if (inputFrames < 2 || (m_resampleInputPos + 1.0) >= inputFrames) + { + break; + } + } + else + { + if (m_resampleInputPos >= inputFrames) + { + break; + } + } + + uint32_t inputFrame = static_cast(m_resampleInputPos); + double frac = m_resampleInputPos - inputFrame; + uint32_t nextFrame = (inputFrame + 1 < inputFrames) ? (inputFrame + 1) : inputFrame; + + for (uint32_t outCh = 0; outCh < m_graphChannels; outCh++) + { + float sample = 0.0f; + + if (m_loopbackChannels == m_graphChannels) + { + uint32_t idx1 = inputFrame * m_loopbackChannels + outCh; + uint32_t idx2 = nextFrame * m_loopbackChannels + outCh; + float s1 = m_resampleInputBuffer[idx1]; + float s2 = m_resampleInputBuffer[idx2]; + sample = static_cast(s1 * (1.0 - frac) + s2 * frac); + } + else if (m_loopbackChannels > m_graphChannels) + { + float sum = 0.0f; + for (uint32_t inCh = 0; inCh < m_loopbackChannels; inCh++) + { + uint32_t idx1 = inputFrame * m_loopbackChannels + inCh; + uint32_t idx2 = nextFrame * m_loopbackChannels + inCh; + float s1 = m_resampleInputBuffer[idx1]; + float s2 = m_resampleInputBuffer[idx2]; + sum += static_cast(s1 * (1.0 - frac) + s2 * frac); + } + sample = sum / m_loopbackChannels; + } + else + { + uint32_t idx1 = inputFrame * m_loopbackChannels; + uint32_t idx2 = nextFrame * m_loopbackChannels; + float s1 = m_resampleInputBuffer[idx1]; + float s2 = m_resampleInputBuffer[idx2]; + sample = static_cast(s1 * (1.0 - frac) + s2 * frac); + } + + resampledSamples.push_back(sample); + } + + m_resampleInputPos += m_resampleRatio; + } + + uint32_t consumedFrames = static_cast(m_resampleInputPos); + if (consumedFrames > 0) + { + size_t samplesToErase = static_cast(consumedFrames) * m_loopbackChannels; + if (samplesToErase >= m_resampleInputBuffer.size()) + { + m_resampleInputBuffer.clear(); + m_resampleInputPos = 0.0; + } + else + { + m_resampleInputBuffer.erase(m_resampleInputBuffer.begin(), m_resampleInputBuffer.begin() + samplesToErase); + m_resampleInputPos -= consumedFrames; + } + } + + if (flushRemaining) + { + m_resampleInputBuffer.clear(); + m_resampleInputPos = 0.0; + } + + if (!resampledSamples.empty()) + { + auto loopbackLock = m_loopbackBufferLock.lock_exclusive(); + const size_t maxBufferSize = static_cast(m_graphSampleRate) * m_graphChannels; + + if (m_loopbackBuffer.size() + resampledSamples.size() > maxBufferSize) + { + size_t overflow = (m_loopbackBuffer.size() + resampledSamples.size()) - maxBufferSize; + if (overflow >= m_loopbackBuffer.size()) + { + m_loopbackBuffer.clear(); + } + else + { + m_loopbackBuffer.erase(m_loopbackBuffer.begin(), m_loopbackBuffer.begin() + overflow); + } + } + + m_loopbackBuffer.insert(m_loopbackBuffer.end(), resampledSamples.begin(), resampledSamples.end()); + } +} + +void AudioSampleGenerator::FlushRemainingAudio() +{ + // Called during stop to drain any remaining samples from loopback capture + // and convert them to MediaStreamSamples before the audio graph stops. + + if (!m_loopbackCapture) + { + return; + } + + auto lock = m_lock.lock_exclusive(); + + // Drain all remaining samples from the loopback capture client + std::vector rawLoopbackSamples; + { + std::vector tempSamples; + while (m_loopbackCapture->TryGetSamples(tempSamples)) + { + rawLoopbackSamples.insert(rawLoopbackSamples.end(), tempSamples.begin(), tempSamples.end()); + } + } + + // Resample and channel-convert the loopback audio to match AudioGraph format + if (!rawLoopbackSamples.empty()) + { + AppendResampledLoopbackSamples(rawLoopbackSamples, true); + } + + // Now convert everything in m_loopbackBuffer to MediaStreamSamples + auto loopbackLock = m_loopbackBufferLock.lock_exclusive(); + + if (!m_loopbackBuffer.empty()) + { + uint32_t outputSampleCount = static_cast(m_loopbackBuffer.size()); + std::vector outputData(outputSampleCount * sizeof(float), 0); + float* outputFloats = reinterpret_cast(outputData.data()); + + for (uint32_t i = 0; i < outputSampleCount; i++) + { + float sample = m_loopbackBuffer[i]; + if (sample > 1.0f) sample = 1.0f; + else if (sample < -1.0f) sample = -1.0f; + outputFloats[i] = sample; + } + + m_loopbackBuffer.clear(); + + // Create buffer and sample + winrt::Buffer sampleBuffer(outputSampleCount * sizeof(float)); + memcpy(sampleBuffer.data(), outputData.data(), outputData.size()); + sampleBuffer.Length(static_cast(outputData.size())); + + if (sampleBuffer.Length() > 0) + { + const uint32_t sampleCount = sampleBuffer.Length() / sizeof(float); + const uint32_t frames = (m_graphChannels > 0) ? (sampleCount / m_graphChannels) : 0; + const int64_t durationTicks = (m_graphSampleRate > 0) ? (static_cast(frames) * 10000000LL / m_graphSampleRate) : 0; + const winrt::TimeSpan duration{ durationTicks }; + + winrt::TimeSpan timestamp{ 0 }; + if (m_hasLastSampleTimestamp) + { + timestamp = winrt::TimeSpan{ m_lastSampleTimestamp.count() + m_lastSampleDuration.count() }; + } + + auto sample = winrt::MediaStreamSample::CreateFromBuffer(sampleBuffer, timestamp); + m_samples.push_back(sample); + m_audioEvent.SetEvent(); + + m_lastSampleTimestamp = timestamp; + m_lastSampleDuration = duration; + m_hasLastSampleTimestamp = true; + } + } +} + +void AudioSampleGenerator::CombineQueuedSamples() +{ + // Combine all queued samples into a single sample so it can be returned + // immediately in the next TryGetNextSample call. This is critical because + // once video ends, the MediaTranscoder may only request one more audio sample. + + auto lock = m_lock.lock_exclusive(); + + if (m_samples.size() <= 1) + { + return; + } + + // Calculate total size and collect all sample data + size_t totalBytes = 0; + std::vector> buffers; + winrt::Windows::Foundation::TimeSpan firstTimestamp{ 0 }; + bool hasFirstTimestamp = false; + + for (auto& sample : m_samples) + { + auto buffer = sample.Buffer(); + if (buffer) + { + totalBytes += buffer.Length(); + if (!hasFirstTimestamp) + { + firstTimestamp = sample.Timestamp(); + hasFirstTimestamp = true; + } + buffers.push_back({ buffer, sample.Timestamp() }); + } + } + + if (totalBytes == 0) + { + return; + } + + // Create combined buffer + winrt::Buffer combinedBuffer(static_cast(totalBytes)); + uint8_t* dest = combinedBuffer.data(); + uint32_t offset = 0; + + for (auto& [buffer, ts] : buffers) + { + uint32_t len = buffer.Length(); + memcpy(dest + offset, buffer.data(), len); + offset += len; + } + combinedBuffer.Length(static_cast(totalBytes)); + + // Create combined sample with first timestamp + auto combinedSample = winrt::Windows::Media::Core::MediaStreamSample::CreateFromBuffer(combinedBuffer, firstTimestamp); + + // Clear queue and add combined sample + m_samples.clear(); + m_samples.push_back(combinedSample); + + // Update timestamp tracking + const uint32_t sampleCount = static_cast(totalBytes) / sizeof(float); + const uint32_t frames = (m_graphChannels > 0) ? (sampleCount / m_graphChannels) : 0; + const int64_t durationTicks = (m_graphSampleRate > 0) ? (static_cast(frames) * 10000000LL / m_graphSampleRate) : 0; + m_lastSampleTimestamp = firstTimestamp; + m_lastSampleDuration = winrt::Windows::Foundation::TimeSpan{ durationTicks }; + m_hasLastSampleTimestamp = true; } void AudioSampleGenerator::OnAudioQuantumStarted(winrt::AudioGraph const& sender, winrt::IInspectable const& args) { + // Don't process if we're not actively recording + if (!m_started.load()) + { + return; + } + { auto lock = m_lock.lock_exclusive(); @@ -159,10 +622,101 @@ void AudioSampleGenerator::OnAudioQuantumStarted(winrt::AudioGraph const& sender std::optional timestamp = frame.RelativeTime(); auto audioBuffer = frame.LockBuffer(winrt::AudioBufferAccessMode::Read); + // Get mic audio as a buffer (may be empty if no microphone) auto sampleBuffer = winrt::Buffer::CreateCopyFromMemoryBuffer(audioBuffer); sampleBuffer.Length(audioBuffer.Length()); - auto sample = winrt::MediaStreamSample::CreateFromBuffer(sampleBuffer, timestamp.value()); - m_samples.push_back(sample); + + // Calculate expected samples per quantum (~10ms at graph sample rate) + // AudioGraph uses 10ms quantums by default + uint32_t expectedSamplesPerQuantum = (m_graphSampleRate / 100) * m_graphChannels; + uint32_t numMicSamples = audioBuffer.Length() / sizeof(float); + + // Drain loopback samples regardless of whether we have mic audio + if (m_loopbackCapture) + { + std::vector rawLoopbackSamples; + { + std::vector tempSamples; + while (m_loopbackCapture->TryGetSamples(tempSamples)) + { + rawLoopbackSamples.insert(rawLoopbackSamples.end(), tempSamples.begin(), tempSamples.end()); + } + } + + // Resample and channel-convert the loopback audio to match AudioGraph format + if (!rawLoopbackSamples.empty()) + { + AppendResampledLoopbackSamples(rawLoopbackSamples); + } + } + + // Determine the actual number of samples we'll output + // Use mic sample count if mic is enabled + uint32_t outputSampleCount = m_captureMicrophone ? numMicSamples : expectedSamplesPerQuantum; + + // If microphone is disabled, create a buffer with only loopback audio + if (!m_captureMicrophone && outputSampleCount > 0) + { + // Create a buffer filled with loopback audio or silence + std::vector outputData(outputSampleCount * sizeof(float), 0); + float* outputFloats = reinterpret_cast(outputData.data()); + + { + auto loopbackLock = m_loopbackBufferLock.lock_exclusive(); + uint32_t samplesToUse = min(outputSampleCount, static_cast(m_loopbackBuffer.size())); + + for (uint32_t i = 0; i < samplesToUse; i++) + { + float sample = m_loopbackBuffer[i]; + if (sample > 1.0f) sample = 1.0f; + else if (sample < -1.0f) sample = -1.0f; + outputFloats[i] = sample; + } + + if (samplesToUse > 0) + { + m_loopbackBuffer.erase(m_loopbackBuffer.begin(), m_loopbackBuffer.begin() + samplesToUse); + } + } + + // Create a new buffer with our loopback data + sampleBuffer = winrt::Buffer(outputSampleCount * sizeof(float)); + memcpy(sampleBuffer.data(), outputData.data(), outputData.size()); + sampleBuffer.Length(static_cast(outputData.size())); + } + else if (m_captureMicrophone && numMicSamples > 0) + { + // Mix loopback into mic samples + auto loopbackLock = m_loopbackBufferLock.lock_exclusive(); + float* bufferData = reinterpret_cast(sampleBuffer.data()); + uint32_t samplesToMix = min(numMicSamples, static_cast(m_loopbackBuffer.size())); + + for (uint32_t i = 0; i < samplesToMix; i++) + { + float mixed = bufferData[i] + m_loopbackBuffer[i]; + if (mixed > 1.0f) mixed = 1.0f; + else if (mixed < -1.0f) mixed = -1.0f; + bufferData[i] = mixed; + } + + if (samplesToMix > 0) + { + m_loopbackBuffer.erase(m_loopbackBuffer.begin(), m_loopbackBuffer.begin() + samplesToMix); + } + } + + if (sampleBuffer.Length() > 0) + { + auto sample = winrt::MediaStreamSample::CreateFromBuffer(sampleBuffer, timestamp.value()); + m_samples.push_back(sample); + + const uint32_t sampleCount = sampleBuffer.Length() / sizeof(float); + const uint32_t frames = (m_graphChannels > 0) ? (sampleCount / m_graphChannels) : 0; + const int64_t durationTicks = (m_graphSampleRate > 0) ? (static_cast(frames) * 10000000LL / m_graphSampleRate) : 0; + m_lastSampleTimestamp = timestamp.value(); + m_lastSampleDuration = winrt::TimeSpan{ durationTicks }; + m_hasLastSampleTimestamp = true; + } } m_audioEvent.SetEvent(); } diff --git a/src/modules/ZoomIt/ZoomIt/AudioSampleGenerator.h b/src/modules/ZoomIt/ZoomIt/AudioSampleGenerator.h index 8e279f3b58..7ffe1438b7 100644 --- a/src/modules/ZoomIt/ZoomIt/AudioSampleGenerator.h +++ b/src/modules/ZoomIt/ZoomIt/AudioSampleGenerator.h @@ -1,9 +1,11 @@ #pragma once +#include "LoopbackCapture.h" + class AudioSampleGenerator { public: - AudioSampleGenerator(); + AudioSampleGenerator(bool captureMicrophone = true, bool captureSystemAudio = true); ~AudioSampleGenerator(); winrt::Windows::Foundation::IAsyncAction InitializeAsync(); @@ -18,6 +20,10 @@ private: winrt::Windows::Media::Audio::AudioGraph const& sender, winrt::Windows::Foundation::IInspectable const& args); + void FlushRemainingAudio(); + void CombineQueuedSamples(); + void AppendResampledLoopbackSamples(std::vector const& rawLoopbackSamples, bool flushRemaining = false); + void CheckInitialized() { if (!m_initialized.load()) @@ -37,12 +43,31 @@ private: private: winrt::Windows::Media::Audio::AudioGraph m_audioGraph{ nullptr }; winrt::Windows::Media::Audio::AudioDeviceInputNode m_audioInputNode{ nullptr }; + winrt::Windows::Media::Audio::AudioSubmixNode m_submixNode{ nullptr }; winrt::Windows::Media::Audio::AudioFrameOutputNode m_audioOutputNode{ nullptr }; + + std::unique_ptr m_loopbackCapture; + std::vector m_loopbackBuffer; // Accumulated loopback samples (resampled to match AudioGraph) + wil::srwlock m_loopbackBufferLock; + uint32_t m_loopbackChannels = 2; + uint32_t m_loopbackSampleRate = 48000; + uint32_t m_graphSampleRate = 48000; + uint32_t m_graphChannels = 2; + double m_resampleRatio = 1.0; // loopbackSampleRate / graphSampleRate + winrt::Windows::Foundation::TimeSpan m_lastSampleTimestamp{}; + winrt::Windows::Foundation::TimeSpan m_lastSampleDuration{}; + bool m_hasLastSampleTimestamp = false; + std::vector m_resampleInputBuffer; // raw loopback samples buffered for resampling + double m_resampleInputPos = 0.0; // fractional input frame position for resampling + wil::srwlock m_lock; wil::unique_event m_audioEvent; wil::unique_event m_endEvent; + wil::unique_event m_startEvent; wil::unique_event m_asyncInitialized; std::deque m_samples; std::atomic m_initialized = false; std::atomic m_started = false; + bool m_captureMicrophone = true; + bool m_captureSystemAudio = true; }; \ No newline at end of file diff --git a/src/modules/ZoomIt/ZoomIt/DemoType.cpp b/src/modules/ZoomIt/ZoomIt/DemoType.cpp index 40284a795b..6aefbf40ca 100644 --- a/src/modules/ZoomIt/ZoomIt/DemoType.cpp +++ b/src/modules/ZoomIt/ZoomIt/DemoType.cpp @@ -846,7 +846,6 @@ LRESULT CALLBACK DemoTypeHookProc( int nCode, WPARAM wParam, LPARAM lParam ) if( g_UserDriven ) { // Set baseline indentation to a blocking flag - // Otherwise indentation seeking will trigger user-driven injection events g_BaselineIndentation = INDENT_SEEK_FLAG; // Initialize the injection handler diff --git a/src/modules/ZoomIt/ZoomIt/GifRecordingSession.cpp b/src/modules/ZoomIt/ZoomIt/GifRecordingSession.cpp index 22e2079f71..18b08b6cf5 100644 --- a/src/modules/ZoomIt/ZoomIt/GifRecordingSession.cpp +++ b/src/modules/ZoomIt/ZoomIt/GifRecordingSession.cpp @@ -242,6 +242,13 @@ std::shared_ptr GifRecordingSession::Create( //---------------------------------------------------------------------------- HRESULT GifRecordingSession::EncodeFrame(ID3D11Texture2D* frameTexture) { + std::lock_guard lock(m_encoderMutex); + if (m_encoderReleased) + { + OutputDebugStringW(L"EncodeFrame called after encoder released.\n"); + return E_FAIL; + } + try { // Create a staging texture for CPU access @@ -367,6 +374,7 @@ HRESULT GifRecordingSession::EncodeFrame(ID3D11Texture2D* frameTexture) // Increment and log frame count m_frameCount++; + m_hasAnyFrame.store(true); OutputDebugStringW((L"GIF Frame #" + std::to_wstring(m_frameCount) + L" fully encoded and committed\n").c_str()); return S_OK; @@ -405,6 +413,12 @@ winrt::IAsyncAction GifRecordingSession::StartAsync() { captureAttempts++; auto frame = m_frameWait->TryGetNextFrame(); + if (!frame && !m_isRecording) + { + // Recording was stopped while waiting for frame + OutputDebugStringW(L"[GIF] Recording stopped during frame wait\n"); + break; + } winrt::com_ptr croppedTexture; @@ -472,8 +486,17 @@ winrt::IAsyncAction GifRecordingSession::StartAsync() // Wait for the next frame interval co_await winrt::resume_after(std::chrono::milliseconds(1000 / m_frameRate)); + + // Check again after resuming from sleep + if (!m_isRecording || m_closed) + { + OutputDebugStringW(L"[GIF] Loop exiting after resume_after\n"); + break; + } } + OutputDebugStringW(L"[GIF] Capture loop exited\n"); + // Commit the GIF encoder if (m_gifEncoder) { @@ -511,6 +534,10 @@ winrt::IAsyncAction GifRecordingSession::StartAsync() CloseInternal(); } } + + // Ensure encoder resources are released in case caller forgets to Close explicitly. + ReleaseEncoderResources(); + OutputDebugStringW(L"[GIF] StartAsync completing, about to co_return\n"); co_return; } @@ -521,18 +548,18 @@ winrt::IAsyncAction GifRecordingSession::StartAsync() //---------------------------------------------------------------------------- void GifRecordingSession::Close() { + OutputDebugStringW(L"[GIF] Close() called\n"); auto expected = false; if (m_closed.compare_exchange_strong(expected, true)) { - expected = true; - if (!m_isRecording.compare_exchange_strong(expected, false)) - { - CloseInternal(); - } - else - { - m_frameWait->StopCapture(); - } + OutputDebugStringW(L"[GIF] Setting m_closed = true\n"); + // Signal the capture loop to stop + m_isRecording = false; + OutputDebugStringW(L"[GIF] Setting m_isRecording = false\n"); + + // Stop the frame wait to unblock any pending frame acquisition + m_frameWait->StopCapture(); + OutputDebugStringW(L"[GIF] StopCapture called\n"); } } @@ -543,6 +570,42 @@ void GifRecordingSession::Close() //---------------------------------------------------------------------------- void GifRecordingSession::CloseInternal() { + ReleaseEncoderResources(); + m_frameWait->StopCapture(); m_itemClosed.revoke(); } + +//---------------------------------------------------------------------------- +// +// GifRecordingSession::ReleaseEncoderResources +// Ensures encoder/stream COM objects release the temp file handle so trim can reopen it. +// +//---------------------------------------------------------------------------- +void GifRecordingSession::ReleaseEncoderResources() +{ + std::lock_guard lock(m_encoderMutex); + if (m_encoderReleased) + { + return; + } + + // Commit only if we still own the encoder and it has not been committed; swallow failures. + if (m_gifEncoder) + { + try + { + m_gifEncoder->Commit(); + } + catch (...) + { + } + } + + m_encoderMetadataWriter = nullptr; + m_gifEncoder = nullptr; + m_wicStream = nullptr; + m_wicFactory = nullptr; + m_stream = nullptr; + m_encoderReleased = true; +} diff --git a/src/modules/ZoomIt/ZoomIt/GifRecordingSession.h b/src/modules/ZoomIt/ZoomIt/GifRecordingSession.h index 90732f60f3..2bffb94fd8 100644 --- a/src/modules/ZoomIt/ZoomIt/GifRecordingSession.h +++ b/src/modules/ZoomIt/ZoomIt/GifRecordingSession.h @@ -11,6 +11,7 @@ #include "CaptureFrameWait.h" #include #include +#include class GifRecordingSession : public std::enable_shared_from_this { @@ -27,6 +28,8 @@ public: void EnableCursorCapture(bool enable = true) { m_frameWait->EnableCursorCapture(enable); } void Close(); + bool HasCapturedFrames() const { return m_hasAnyFrame.load(); } + private: GifRecordingSession( winrt::Direct3D11::IDirect3DDevice const& device, @@ -35,6 +38,7 @@ private: uint32_t frameRate, winrt::Streams::IRandomAccessStream const& stream); void CloseInternal(); + void ReleaseEncoderResources(); HRESULT EncodeFrame(ID3D11Texture2D* texture); private: @@ -58,6 +62,9 @@ private: std::atomic m_isRecording = false; std::atomic m_closed = false; + std::atomic m_encoderReleased = false; + std::atomic m_hasAnyFrame = false; + std::mutex m_encoderMutex; uint32_t m_frameWidth=0; uint32_t m_frameHeight=0; diff --git a/src/modules/ZoomIt/ZoomIt/LoopbackCapture.cpp b/src/modules/ZoomIt/ZoomIt/LoopbackCapture.cpp new file mode 100644 index 0000000000..fb29df3cef --- /dev/null +++ b/src/modules/ZoomIt/ZoomIt/LoopbackCapture.cpp @@ -0,0 +1,337 @@ +#include "pch.h" +#include "LoopbackCapture.h" +#include + +#pragma comment(lib, "ole32.lib") + +LoopbackCapture::LoopbackCapture() +{ + m_stopEvent.create(wil::EventOptions::ManualReset); + m_samplesReadyEvent.create(wil::EventOptions::ManualReset); +} + +LoopbackCapture::~LoopbackCapture() +{ + Stop(); + if (m_pwfx) + { + CoTaskMemFree(m_pwfx); + m_pwfx = nullptr; + } +} + +HRESULT LoopbackCapture::Initialize() +{ + if (m_initialized.load()) + { + return S_OK; + } + + HRESULT hr = CoCreateInstance( + __uuidof(MMDeviceEnumerator), + nullptr, + CLSCTX_ALL, + __uuidof(IMMDeviceEnumerator), + m_deviceEnumerator.put_void()); + if (FAILED(hr)) + { + return hr; + } + + // Get the default audio render device (speakers/headphones) + hr = m_deviceEnumerator->GetDefaultAudioEndpoint(eRender, eConsole, m_device.put()); + if (FAILED(hr)) + { + return hr; + } + + hr = m_device->Activate(__uuidof(IAudioClient), CLSCTX_ALL, nullptr, m_audioClient.put_void()); + if (FAILED(hr)) + { + return hr; + } + + // Get the mix format + hr = m_audioClient->GetMixFormat(&m_pwfx); + if (FAILED(hr)) + { + return hr; + } + + // Initialize audio client in loopback mode + // AUDCLNT_STREAMFLAGS_LOOPBACK enables capturing what's being played on the device + hr = m_audioClient->Initialize( + AUDCLNT_SHAREMODE_SHARED, + AUDCLNT_STREAMFLAGS_LOOPBACK, + 1000000, // 100ms buffer to reduce capture latency + 0, + m_pwfx, + nullptr); + if (FAILED(hr)) + { + return hr; + } + + hr = m_audioClient->GetService(__uuidof(IAudioCaptureClient), m_captureClient.put_void()); + if (FAILED(hr)) + { + return hr; + } + + m_initialized.store(true); + return S_OK; +} + +HRESULT LoopbackCapture::Start() +{ + if (!m_initialized.load()) + { + return E_NOT_VALID_STATE; + } + + if (m_started.load()) + { + return S_OK; + } + + m_stopEvent.ResetEvent(); + + HRESULT hr = m_audioClient->Start(); + if (FAILED(hr)) + { + return hr; + } + + m_started.store(true); + + // Start capture thread + m_captureThread = std::thread(&LoopbackCapture::CaptureThread, this); + + return S_OK; +} + +void LoopbackCapture::Stop() +{ + if (!m_started.load()) + { + return; + } + + m_stopEvent.SetEvent(); + + if (m_captureThread.joinable()) + { + m_captureThread.join(); + } + + DrainCaptureClient(); + + if (m_audioClient) + { + m_audioClient->Stop(); + } + + m_started.store(false); +} + +void LoopbackCapture::DrainCaptureClient() +{ + if (!m_captureClient) + { + return; + } + + while (true) + { + UINT32 packetLength = 0; + HRESULT hr = m_captureClient->GetNextPacketSize(&packetLength); + if (FAILED(hr) || packetLength == 0) + { + break; + } + + BYTE* pData = nullptr; + UINT32 numFramesAvailable = 0; + DWORD flags = 0; + hr = m_captureClient->GetBuffer(&pData, &numFramesAvailable, &flags, nullptr, nullptr); + if (FAILED(hr)) + { + break; + } + + if (numFramesAvailable > 0) + { + std::vector samples; + + if (m_pwfx->wFormatTag == WAVE_FORMAT_IEEE_FLOAT || + (m_pwfx->wFormatTag == WAVE_FORMAT_EXTENSIBLE && + reinterpret_cast(m_pwfx)->SubFormat == KSDATAFORMAT_SUBTYPE_IEEE_FLOAT)) + { + if (flags & AUDCLNT_BUFFERFLAGS_SILENT) + { + samples.resize(static_cast(numFramesAvailable) * m_pwfx->nChannels, 0.0f); + } + else + { + float* floatData = reinterpret_cast(pData); + samples.assign(floatData, floatData + (static_cast(numFramesAvailable) * m_pwfx->nChannels)); + } + } + else if (m_pwfx->wFormatTag == WAVE_FORMAT_PCM || + (m_pwfx->wFormatTag == WAVE_FORMAT_EXTENSIBLE && + reinterpret_cast(m_pwfx)->SubFormat == KSDATAFORMAT_SUBTYPE_PCM)) + { + if (flags & AUDCLNT_BUFFERFLAGS_SILENT) + { + samples.resize(static_cast(numFramesAvailable) * m_pwfx->nChannels, 0.0f); + } + else if (m_pwfx->wBitsPerSample == 16) + { + int16_t* pcmData = reinterpret_cast(pData); + samples.resize(static_cast(numFramesAvailable) * m_pwfx->nChannels); + for (size_t i = 0; i < samples.size(); i++) + { + samples[i] = static_cast(pcmData[i]) / 32768.0f; + } + } + else if (m_pwfx->wBitsPerSample == 32) + { + int32_t* pcmData = reinterpret_cast(pData); + samples.resize(static_cast(numFramesAvailable) * m_pwfx->nChannels); + for (size_t i = 0; i < samples.size(); i++) + { + samples[i] = static_cast(pcmData[i]) / 2147483648.0f; + } + } + } + + if (!samples.empty()) + { + auto lock = m_lock.lock_exclusive(); + m_sampleQueue.push_back(std::move(samples)); + m_samplesReadyEvent.SetEvent(); + } + } + + hr = m_captureClient->ReleaseBuffer(numFramesAvailable); + if (FAILED(hr)) + { + break; + } + } +} + +void LoopbackCapture::CaptureThread() +{ + while (WaitForSingleObject(m_stopEvent.get(), 10) == WAIT_TIMEOUT) + { + UINT32 packetLength = 0; + HRESULT hr = m_captureClient->GetNextPacketSize(&packetLength); + if (FAILED(hr)) + { + break; + } + + while (packetLength != 0) + { + BYTE* pData = nullptr; + UINT32 numFramesAvailable = 0; + DWORD flags = 0; + + hr = m_captureClient->GetBuffer(&pData, &numFramesAvailable, &flags, nullptr, nullptr); + if (FAILED(hr)) + { + break; + } + + if (numFramesAvailable > 0) + { + std::vector samples; + + // Convert to float samples + if (m_pwfx->wFormatTag == WAVE_FORMAT_IEEE_FLOAT || + (m_pwfx->wFormatTag == WAVE_FORMAT_EXTENSIBLE && + reinterpret_cast(m_pwfx)->SubFormat == KSDATAFORMAT_SUBTYPE_IEEE_FLOAT)) + { + // Already float format + if (flags & AUDCLNT_BUFFERFLAGS_SILENT) + { + // Insert silence + samples.resize(static_cast(numFramesAvailable) * m_pwfx->nChannels, 0.0f); + } + else + { + float* floatData = reinterpret_cast(pData); + samples.assign(floatData, floatData + (static_cast(numFramesAvailable) * m_pwfx->nChannels)); + } + } + else if (m_pwfx->wFormatTag == WAVE_FORMAT_PCM || + (m_pwfx->wFormatTag == WAVE_FORMAT_EXTENSIBLE && + reinterpret_cast(m_pwfx)->SubFormat == KSDATAFORMAT_SUBTYPE_PCM)) + { + // Convert PCM to float + if (flags & AUDCLNT_BUFFERFLAGS_SILENT) + { + samples.resize(static_cast(numFramesAvailable) * m_pwfx->nChannels, 0.0f); + } + else if (m_pwfx->wBitsPerSample == 16) + { + int16_t* pcmData = reinterpret_cast(pData); + samples.resize(static_cast(numFramesAvailable) * m_pwfx->nChannels); + for (size_t i = 0; i < samples.size(); i++) + { + samples[i] = static_cast(pcmData[i]) / 32768.0f; + } + } + else if (m_pwfx->wBitsPerSample == 32) + { + int32_t* pcmData = reinterpret_cast(pData); + samples.resize(static_cast(numFramesAvailable) * m_pwfx->nChannels); + for (size_t i = 0; i < samples.size(); i++) + { + samples[i] = static_cast(pcmData[i]) / 2147483648.0f; + } + } + } + + if (!samples.empty()) + { + auto lock = m_lock.lock_exclusive(); + m_sampleQueue.push_back(std::move(samples)); + m_samplesReadyEvent.SetEvent(); + } + } + + hr = m_captureClient->ReleaseBuffer(numFramesAvailable); + if (FAILED(hr)) + { + break; + } + + hr = m_captureClient->GetNextPacketSize(&packetLength); + if (FAILED(hr)) + { + break; + } + } + } +} + +bool LoopbackCapture::TryGetSamples(std::vector& samples) +{ + auto lock = m_lock.lock_exclusive(); + if (m_sampleQueue.empty()) + { + return false; + } + + samples = std::move(m_sampleQueue.front()); + m_sampleQueue.pop_front(); + + if (m_sampleQueue.empty()) + { + m_samplesReadyEvent.ResetEvent(); + } + + return true; +} diff --git a/src/modules/ZoomIt/ZoomIt/LoopbackCapture.h b/src/modules/ZoomIt/ZoomIt/LoopbackCapture.h new file mode 100644 index 0000000000..53f70817b5 --- /dev/null +++ b/src/modules/ZoomIt/ZoomIt/LoopbackCapture.h @@ -0,0 +1,46 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +class LoopbackCapture +{ +public: + LoopbackCapture(); + ~LoopbackCapture(); + + HRESULT Initialize(); + HRESULT Start(); + void Stop(); + + // Returns audio samples in the format: PCM float, stereo, 48kHz + bool TryGetSamples(std::vector& samples); + + WAVEFORMATEX* GetFormat() const { return m_pwfx; } + uint32_t GetSampleRate() const { return m_pwfx ? m_pwfx->nSamplesPerSec : 48000; } + uint32_t GetChannels() const { return m_pwfx ? m_pwfx->nChannels : 2; } + +private: + void CaptureThread(); + void DrainCaptureClient(); + + winrt::com_ptr m_deviceEnumerator; + winrt::com_ptr m_device; + winrt::com_ptr m_audioClient; + winrt::com_ptr m_captureClient; + WAVEFORMATEX* m_pwfx{ nullptr }; + + wil::unique_event m_stopEvent; + wil::unique_event m_samplesReadyEvent; + std::thread m_captureThread; + + wil::srwlock m_lock; + std::deque> m_sampleQueue; + + std::atomic m_initialized{ false }; + std::atomic m_started{ false }; +}; diff --git a/src/modules/ZoomIt/ZoomIt/Utility.cpp b/src/modules/ZoomIt/ZoomIt/Utility.cpp index ccc72ad752..f4e170c804 100644 --- a/src/modules/ZoomIt/ZoomIt/Utility.cpp +++ b/src/modules/ZoomIt/ZoomIt/Utility.cpp @@ -8,6 +8,579 @@ //============================================================================== #include "pch.h" #include "Utility.h" +#include + +#pragma comment(lib, "uxtheme.lib") + +//---------------------------------------------------------------------------- +// Dark Mode - Static/Global State +//---------------------------------------------------------------------------- +static bool g_darkModeInitialized = false; +static bool g_darkModeEnabled = false; +static HBRUSH g_darkBackgroundBrush = nullptr; +static HBRUSH g_darkControlBrush = nullptr; +static HBRUSH g_darkSurfaceBrush = nullptr; + +// Theme override from registry (defined in ZoomItSettings.h) +extern DWORD g_ThemeOverride; + +// Preferred App Mode values for Windows 10/11 dark mode +enum class PreferredAppMode +{ + Default, + AllowDark, + ForceDark, + ForceLight, + Max +}; + +// Undocumented ordinals from uxtheme.dll for dark mode support +using fnSetPreferredAppMode = PreferredAppMode(WINAPI*)(PreferredAppMode appMode); +using fnAllowDarkModeForWindow = bool(WINAPI*)(HWND hWnd, bool allow); +using fnShouldAppsUseDarkMode = bool(WINAPI*)(); +using fnRefreshImmersiveColorPolicyState = void(WINAPI*)(); +using fnFlushMenuThemes = void(WINAPI*)(); + +static fnSetPreferredAppMode pSetPreferredAppMode = nullptr; +static fnAllowDarkModeForWindow pAllowDarkModeForWindow = nullptr; +static fnShouldAppsUseDarkMode pShouldAppsUseDarkMode = nullptr; +static fnRefreshImmersiveColorPolicyState pRefreshImmersiveColorPolicyState = nullptr; +static fnFlushMenuThemes pFlushMenuThemes = nullptr; + +//---------------------------------------------------------------------------- +// +// InitializeDarkModeSupport +// +// Initialize dark mode function pointers from uxtheme.dll +// +//---------------------------------------------------------------------------- +static void InitializeDarkModeSupport() +{ + if (g_darkModeInitialized) + return; + + g_darkModeInitialized = true; + + HMODULE hUxTheme = GetModuleHandleW(L"uxtheme.dll"); + if (hUxTheme) + { + // These are undocumented ordinal exports + // Ordinal 135: SetPreferredAppMode (Windows 10 1903+) + pSetPreferredAppMode = reinterpret_cast( + GetProcAddress(hUxTheme, MAKEINTRESOURCEA(135))); + // Ordinal 133: AllowDarkModeForWindow + pAllowDarkModeForWindow = reinterpret_cast( + GetProcAddress(hUxTheme, MAKEINTRESOURCEA(133))); + // Ordinal 132: ShouldAppsUseDarkMode + pShouldAppsUseDarkMode = reinterpret_cast( + GetProcAddress(hUxTheme, MAKEINTRESOURCEA(132))); + // Ordinal 104: RefreshImmersiveColorPolicyState + pRefreshImmersiveColorPolicyState = reinterpret_cast( + GetProcAddress(hUxTheme, MAKEINTRESOURCEA(104))); + // Ordinal 136: FlushMenuThemes + pFlushMenuThemes = reinterpret_cast( + GetProcAddress(hUxTheme, MAKEINTRESOURCEA(136))); + + // Set preferred app mode based on our theme override or system setting + // Note: We check g_ThemeOverride directly here because IsDarkModeEnabled + // calls InitializeDarkModeSupport, which would cause recursion + if (pSetPreferredAppMode) + { + bool useDarkMode = false; + if (g_ThemeOverride == 0) + { + useDarkMode = false; // Force light + } + else if (g_ThemeOverride == 1) + { + useDarkMode = true; // Force dark + } + else if (pShouldAppsUseDarkMode) + { + useDarkMode = pShouldAppsUseDarkMode(); // Use system setting + } + + if (useDarkMode) + { + pSetPreferredAppMode(PreferredAppMode::ForceDark); + } + else + { + pSetPreferredAppMode(PreferredAppMode::ForceLight); + } + } + + // Flush menu themes to apply dark mode to context menus + if (pFlushMenuThemes) + { + pFlushMenuThemes(); + } + } + + // Update cached dark mode state + g_darkModeEnabled = false; + if (g_ThemeOverride == 0) + { + g_darkModeEnabled = false; + } + else if (g_ThemeOverride == 1) + { + g_darkModeEnabled = true; + } + else if (pShouldAppsUseDarkMode) + { + g_darkModeEnabled = pShouldAppsUseDarkMode(); + } +} + +//---------------------------------------------------------------------------- +// +// IsDarkModeEnabled +// +//---------------------------------------------------------------------------- +bool IsDarkModeEnabled() +{ + // Check for theme override from registry (0=light, 1=dark, 2+=system) + if (g_ThemeOverride == 0) + { + return false; // Force light mode + } + else if (g_ThemeOverride == 1) + { + return true; // Force dark mode + } + + InitializeDarkModeSupport(); + + // Check the undocumented API first + if (pShouldAppsUseDarkMode) + { + return pShouldAppsUseDarkMode(); + } + + // Fallback: Check registry for system theme preference + HKEY hKey; + if (RegOpenKeyExW(HKEY_CURRENT_USER, + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", + 0, KEY_READ, &hKey) == ERROR_SUCCESS) + { + DWORD value = 1; + DWORD size = sizeof(value); + RegQueryValueExW(hKey, L"AppsUseLightTheme", nullptr, nullptr, + reinterpret_cast(&value), &size); + RegCloseKey(hKey); + return value == 0; // 0 = dark mode, 1 = light mode + } + + return false; +} + +//---------------------------------------------------------------------------- +// +// RefreshDarkModeState +// +//---------------------------------------------------------------------------- +void RefreshDarkModeState() +{ + InitializeDarkModeSupport(); + + if (pRefreshImmersiveColorPolicyState) + { + pRefreshImmersiveColorPolicyState(); + } + + // Update preferred app mode based on our IsDarkModeEnabled (respects override) + bool useDark = IsDarkModeEnabled(); + if (pSetPreferredAppMode) + { + if (useDark) + { + pSetPreferredAppMode(PreferredAppMode::ForceDark); + } + else + { + pSetPreferredAppMode(PreferredAppMode::ForceLight); + } + } + + // Flush menu themes to apply dark mode to context menus + if (pFlushMenuThemes) + { + pFlushMenuThemes(); + } + + g_darkModeEnabled = useDark; +} + +//---------------------------------------------------------------------------- +// +// SetDarkModeForWindow +// +//---------------------------------------------------------------------------- +void SetDarkModeForWindow(HWND hWnd, bool enable) +{ + InitializeDarkModeSupport(); + + if (pAllowDarkModeForWindow) + { + pAllowDarkModeForWindow(hWnd, enable); + } + + // Use DWMWA_USE_IMMERSIVE_DARK_MODE attribute (Windows 10 build 17763+) + // Attribute 20 is DWMWA_USE_IMMERSIVE_DARK_MODE + BOOL useDarkMode = enable ? TRUE : FALSE; + HMODULE hDwmapi = GetModuleHandleW(L"dwmapi.dll"); + if (hDwmapi) + { + using fnDwmSetWindowAttribute = HRESULT(WINAPI*)(HWND, DWORD, LPCVOID, DWORD); + auto pDwmSetWindowAttribute = reinterpret_cast( + GetProcAddress(hDwmapi, "DwmSetWindowAttribute")); + if (pDwmSetWindowAttribute) + { + // Try attribute 20 first (Windows 11 / newer Windows 10) + HRESULT hr = pDwmSetWindowAttribute(hWnd, 20, &useDarkMode, sizeof(useDarkMode)); + if (FAILED(hr)) + { + // Fall back to attribute 19 (older Windows 10) + pDwmSetWindowAttribute(hWnd, 19, &useDarkMode, sizeof(useDarkMode)); + } + } + } +} + +//---------------------------------------------------------------------------- +// +// GetDarkModeBrush / GetDarkModeControlBrush / GetDarkModeSurfaceBrush +// +//---------------------------------------------------------------------------- +HBRUSH GetDarkModeBrush() +{ + if (!g_darkBackgroundBrush) + { + g_darkBackgroundBrush = CreateSolidBrush(DarkMode::BackgroundColor); + } + return g_darkBackgroundBrush; +} + +HBRUSH GetDarkModeControlBrush() +{ + if (!g_darkControlBrush) + { + g_darkControlBrush = CreateSolidBrush(DarkMode::ControlColor); + } + return g_darkControlBrush; +} + +HBRUSH GetDarkModeSurfaceBrush() +{ + if (!g_darkSurfaceBrush) + { + g_darkSurfaceBrush = CreateSolidBrush(DarkMode::SurfaceColor); + } + return g_darkSurfaceBrush; +} + +//---------------------------------------------------------------------------- +// +// ApplyDarkModeToDialog +// +//---------------------------------------------------------------------------- +void ApplyDarkModeToDialog(HWND hDlg) +{ + if (IsDarkModeEnabled()) + { + SetDarkModeForWindow(hDlg, true); + + // Set dark theme for the dialog + SetWindowTheme(hDlg, L"DarkMode_Explorer", nullptr); + + // Apply dark theme to common controls (buttons, edit boxes, etc.) + EnumChildWindows(hDlg, [](HWND hChild, LPARAM) -> BOOL { + wchar_t className[64] = { 0 }; + GetClassNameW(hChild, className, _countof(className)); + + // Apply appropriate theme based on control type + if (_wcsicmp(className, L"Button") == 0) + { + // Check if this is a checkbox or radio button + LONG style = GetWindowLong(hChild, GWL_STYLE); + LONG buttonType = style & BS_TYPEMASK; + if (buttonType == BS_CHECKBOX || buttonType == BS_AUTOCHECKBOX || + buttonType == BS_3STATE || buttonType == BS_AUTO3STATE || + buttonType == BS_RADIOBUTTON || buttonType == BS_AUTORADIOBUTTON) + { + // Subclass checkbox/radio for dark mode painting - but keep DarkMode_Explorer theme + // for proper hit testing (empty theme can break mouse interaction) + SetWindowTheme(hChild, L"DarkMode_Explorer", nullptr); + SetWindowSubclass(hChild, CheckboxSubclassProc, 2, 0); + } + else if (buttonType == BS_GROUPBOX) + { + // Subclass group box for dark mode painting + SetWindowTheme(hChild, L"", L""); + SetWindowSubclass(hChild, GroupBoxSubclassProc, 4, 0); + } + else + { + SetWindowTheme(hChild, L"DarkMode_Explorer", nullptr); + } + } + else if (_wcsicmp(className, L"Edit") == 0) + { + // Use empty theme and subclass for dark mode border drawing + SetWindowTheme(hChild, L"", L""); + SetWindowSubclass(hChild, EditControlSubclassProc, 3, 0); + } + else if (_wcsicmp(className, L"ComboBox") == 0) + { + SetWindowTheme(hChild, L"DarkMode_CFD", nullptr); + } + else if (_wcsicmp(className, L"SysListView32") == 0 || + _wcsicmp(className, L"SysTreeView32") == 0) + { + SetWindowTheme(hChild, L"DarkMode_Explorer", nullptr); + } + else if (_wcsicmp(className, L"msctls_trackbar32") == 0) + { + // Subclass trackbar controls for dark mode painting + SetWindowTheme(hChild, L"", L""); + SetWindowSubclass(hChild, SliderSubclassProc, 1, 0); + } + else if (_wcsicmp(className, L"SysTabControl32") == 0) + { + // Use empty theme for tab control to allow dark background + SetWindowTheme(hChild, L"", L""); + } + else if (_wcsicmp(className, L"msctls_updown32") == 0) + { + SetWindowTheme(hChild, L"DarkMode_Explorer", nullptr); + } + else if (_wcsicmp(className, L"msctls_hotkey32") == 0) + { + // Subclass hotkey controls for dark mode painting + SetWindowTheme(hChild, L"", L""); + SetWindowSubclass(hChild, HotkeyControlSubclassProc, 1, 0); + } + else if (_wcsicmp(className, L"Static") == 0) + { + // Check if this is a text label (not an owner-draw or image control) + LONG style = GetWindowLong(hChild, GWL_STYLE); + LONG staticType = style & SS_TYPEMASK; + + // Options header uses a dedicated static subclass (to support large title font). + // Avoid applying the generic static subclass on top of it. + const int controlId = GetDlgCtrlID( hChild ); + if( controlId == IDC_VERSION || controlId == IDC_COPYRIGHT ) + { + SetWindowTheme( hChild, L"", L"" ); + return TRUE; + } + + if (staticType == SS_LEFT || staticType == SS_CENTER || staticType == SS_RIGHT || + staticType == SS_LEFTNOWORDWRAP || staticType == SS_SIMPLE) + { + // Subclass text labels for proper dark mode painting + SetWindowTheme(hChild, L"", L""); + SetWindowSubclass(hChild, StaticTextSubclassProc, 5, 0); + } + else + { + // Other static controls (icons, bitmaps, frames) - just remove theme + SetWindowTheme(hChild, L"", L""); + } + } + else + { + SetWindowTheme(hChild, L"DarkMode_Explorer", nullptr); + } + return TRUE; + }, 0); + } + else + { + // Light mode - remove dark mode + SetDarkModeForWindow(hDlg, false); + SetWindowTheme(hDlg, nullptr, nullptr); + + EnumChildWindows(hDlg, [](HWND hChild, LPARAM) -> BOOL { + // Remove subclass from controls + wchar_t className[64] = { 0 }; + GetClassNameW(hChild, className, _countof(className)); + if (_wcsicmp(className, L"msctls_hotkey32") == 0) + { + RemoveWindowSubclass(hChild, HotkeyControlSubclassProc, 1); + } + else if (_wcsicmp(className, L"msctls_trackbar32") == 0) + { + RemoveWindowSubclass(hChild, SliderSubclassProc, 1); + } + else if (_wcsicmp(className, L"Button") == 0) + { + LONG style = GetWindowLong(hChild, GWL_STYLE); + LONG buttonType = style & BS_TYPEMASK; + if (buttonType == BS_CHECKBOX || buttonType == BS_AUTOCHECKBOX || + buttonType == BS_3STATE || buttonType == BS_AUTO3STATE || + buttonType == BS_RADIOBUTTON || buttonType == BS_AUTORADIOBUTTON) + { + RemoveWindowSubclass(hChild, CheckboxSubclassProc, 2); + } + else if (buttonType == BS_GROUPBOX) + { + RemoveWindowSubclass(hChild, GroupBoxSubclassProc, 4); + } + } + else if (_wcsicmp(className, L"Edit") == 0) + { + RemoveWindowSubclass(hChild, EditControlSubclassProc, 3); + } + else if (_wcsicmp(className, L"Static") == 0) + { + RemoveWindowSubclass(hChild, StaticTextSubclassProc, 5); + } + SetWindowTheme(hChild, nullptr, nullptr); + return TRUE; + }, 0); + } +} + +//---------------------------------------------------------------------------- +// +// HandleDarkModeCtlColor +// +//---------------------------------------------------------------------------- +HBRUSH HandleDarkModeCtlColor(HDC hdc, HWND hCtrl, UINT message) +{ + if (!IsDarkModeEnabled()) + { + return nullptr; + } + + switch (message) + { + case WM_CTLCOLORDLG: + SetBkColor(hdc, DarkMode::BackgroundColor); + SetTextColor(hdc, DarkMode::TextColor); + return GetDarkModeBrush(); + + case WM_CTLCOLORSTATIC: + SetBkMode(hdc, TRANSPARENT); + // Use dimmed color for disabled static controls + if (!IsWindowEnabled(hCtrl)) + { + SetTextColor(hdc, RGB(100, 100, 100)); + } + else + { + SetTextColor(hdc, DarkMode::TextColor); + } + return GetDarkModeBrush(); + + case WM_CTLCOLORBTN: + SetBkColor(hdc, DarkMode::ControlColor); + SetTextColor(hdc, DarkMode::TextColor); + return GetDarkModeControlBrush(); + + case WM_CTLCOLOREDIT: + SetBkColor(hdc, DarkMode::SurfaceColor); + SetTextColor(hdc, DarkMode::TextColor); + return GetDarkModeSurfaceBrush(); + + case WM_CTLCOLORLISTBOX: + SetBkColor(hdc, DarkMode::SurfaceColor); + SetTextColor(hdc, DarkMode::TextColor); + return GetDarkModeSurfaceBrush(); + } + + return nullptr; +} + +//---------------------------------------------------------------------------- +// +// ApplyDarkModeToMenu +// +// Uses undocumented uxtheme functions to enable dark mode for menus +// +//---------------------------------------------------------------------------- +void ApplyDarkModeToMenu(HMENU hMenu) +{ + if (!hMenu) + { + return; + } + + if (!IsDarkModeEnabled()) + { + // Light mode - clear any dark background + MENUINFO mi = { sizeof(mi) }; + mi.fMask = MIM_BACKGROUND | MIM_APPLYTOSUBMENUS; + mi.hbrBack = nullptr; + SetMenuInfo(hMenu, &mi); + return; + } + + // For popup menus, we need to use MENUINFO to set the background + MENUINFO mi = { sizeof(mi) }; + mi.fMask = MIM_BACKGROUND | MIM_APPLYTOSUBMENUS; + mi.hbrBack = GetDarkModeSurfaceBrush(); + SetMenuInfo(hMenu, &mi); +} + +//---------------------------------------------------------------------------- +// +// RefreshWindowTheme +// +// Forces a window and all its children to redraw with current theme +// +//---------------------------------------------------------------------------- +void RefreshWindowTheme(HWND hWnd) +{ + if (!hWnd) + { + return; + } + + // Reapply theme to this window + ApplyDarkModeToDialog(hWnd); + + // Force redraw + RedrawWindow(hWnd, nullptr, nullptr, RDW_INVALIDATE | RDW_ERASE | RDW_ALLCHILDREN | RDW_FRAME); +} + +//---------------------------------------------------------------------------- +// +// CleanupDarkModeResources +// +//---------------------------------------------------------------------------- +void CleanupDarkModeResources() +{ + if (g_darkBackgroundBrush) + { + DeleteObject(g_darkBackgroundBrush); + g_darkBackgroundBrush = nullptr; + } + if (g_darkControlBrush) + { + DeleteObject(g_darkControlBrush); + g_darkControlBrush = nullptr; + } + if (g_darkSurfaceBrush) + { + DeleteObject(g_darkSurfaceBrush); + g_darkSurfaceBrush = nullptr; + } +} + +//---------------------------------------------------------------------------- +// +// InitializeDarkMode +// +// Public wrapper to initialize dark mode support early in app startup +// +//---------------------------------------------------------------------------- +void InitializeDarkMode() +{ + InitializeDarkModeSupport(); +} //---------------------------------------------------------------------------- // @@ -151,3 +724,177 @@ POINT ScalePointInRects( POINT point, const RECT& source, const RECT& target ) return { targetCenter.x + MulDiv( point.x - sourceCenter.x, targetSize.cx, sourceSize.cx ), targetCenter.y + MulDiv( point.y - sourceCenter.y, targetSize.cy, sourceSize.cy ) }; } + +//---------------------------------------------------------------------------- +// +// ScaleDialogForDpi +// +// Scales a dialog and all its child controls for the specified DPI. +// oldDpi defaults to DPI_BASELINE (96) for initial scaling. +// +//---------------------------------------------------------------------------- +void ScaleDialogForDpi( HWND hDlg, UINT newDpi, UINT oldDpi ) +{ + if( newDpi == oldDpi || newDpi == 0 || oldDpi == 0 ) + { + return; + } + + // With PerMonitorV2, Windows automatically scales dialogs (layout and fonts) when created. + // We only need to scale when moving between monitors with different DPIs. + // When oldDpi == DPI_BASELINE, this is initial creation and Windows already handled scaling. + if( oldDpi == DPI_BASELINE ) + { + return; + } + + // Scale the dialog window itself + RECT dialogRect; + GetWindowRect( hDlg, &dialogRect ); + int dialogWidth = MulDiv( dialogRect.right - dialogRect.left, newDpi, oldDpi ); + int dialogHeight = MulDiv( dialogRect.bottom - dialogRect.top, newDpi, oldDpi ); + SetWindowPos( hDlg, nullptr, 0, 0, dialogWidth, dialogHeight, SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE ); + + // Enumerate and scale all child controls + HWND hChild = GetWindow( hDlg, GW_CHILD ); + while( hChild != nullptr ) + { + RECT childRect; + GetWindowRect( hChild, &childRect ); + MapWindowPoints( nullptr, hDlg, reinterpret_cast(&childRect), 2 ); + + int x = MulDiv( childRect.left, newDpi, oldDpi ); + int y = MulDiv( childRect.top, newDpi, oldDpi ); + int width = MulDiv( childRect.right - childRect.left, newDpi, oldDpi ); + int height = MulDiv( childRect.bottom - childRect.top, newDpi, oldDpi ); + + SetWindowPos( hChild, nullptr, x, y, width, height, SWP_NOZORDER | SWP_NOACTIVATE ); + + // Scale the font for the control + HFONT hFont = reinterpret_cast(SendMessage( hChild, WM_GETFONT, 0, 0 )); + if( hFont != nullptr ) + { + LOGFONT lf{}; + if( GetObject( hFont, sizeof(lf), &lf ) ) + { + lf.lfHeight = MulDiv( lf.lfHeight, newDpi, oldDpi ); + HFONT hNewFont = CreateFontIndirect( &lf ); + if( hNewFont ) + { + SendMessage( hChild, WM_SETFONT, reinterpret_cast(hNewFont), TRUE ); + // Note: The old font might be shared, so we don't delete it here + // The system will clean up fonts when the dialog is destroyed + } + } + } + + hChild = GetWindow( hChild, GW_HWNDNEXT ); + } + + // Also scale the dialog's own font + HFONT hDialogFont = reinterpret_cast(SendMessage( hDlg, WM_GETFONT, 0, 0 )); + if( hDialogFont != nullptr ) + { + LOGFONT lf{}; + if( GetObject( hDialogFont, sizeof(lf), &lf ) ) + { + lf.lfHeight = MulDiv( lf.lfHeight, newDpi, oldDpi ); + HFONT hNewFont = CreateFontIndirect( &lf ); + if( hNewFont ) + { + SendMessage( hDlg, WM_SETFONT, reinterpret_cast(hNewFont), TRUE ); + } + } + } +} + +//---------------------------------------------------------------------------- +// +// ScaleChildControlsForDpi +// +// Scales a window's direct child controls (and their fonts) for the specified DPI. +// Unlike ScaleDialogForDpi, this does not resize the parent window itself. +// +// This is useful for child dialogs used as tab pages: the tab page window is +// already scaled when the parent options dialog is scaled, but the controls +// inside the page are not (because they are grandchildren of the options dialog). +// +//---------------------------------------------------------------------------- +void ScaleChildControlsForDpi( HWND hParent, UINT newDpi, UINT oldDpi ) +{ + if( newDpi == oldDpi || newDpi == 0 || oldDpi == 0 ) + { + return; + } + + // With PerMonitorV2, Windows automatically scales dialogs (layout and fonts) when created. + // We only need to scale when moving between monitors with different DPIs. + // When oldDpi == DPI_BASELINE, this is initial creation and Windows already handled scaling. + if( oldDpi == DPI_BASELINE ) + { + return; + } + + HWND hChild = GetWindow( hParent, GW_CHILD ); + while( hChild != nullptr ) + { + RECT childRect; + GetWindowRect( hChild, &childRect ); + MapWindowPoints( nullptr, hParent, reinterpret_cast(&childRect), 2 ); + + int x = MulDiv( childRect.left, newDpi, oldDpi ); + int y = MulDiv( childRect.top, newDpi, oldDpi ); + int width = MulDiv( childRect.right - childRect.left, newDpi, oldDpi ); + int height = MulDiv( childRect.bottom - childRect.top, newDpi, oldDpi ); + + SetWindowPos( hChild, nullptr, x, y, width, height, SWP_NOZORDER | SWP_NOACTIVATE ); + + // Scale the font for the control + HFONT hFont = reinterpret_cast(SendMessage( hChild, WM_GETFONT, 0, 0 )); + if( hFont != nullptr ) + { + LOGFONT lf{}; + if( GetObject( hFont, sizeof(lf), &lf ) ) + { + lf.lfHeight = MulDiv( lf.lfHeight, newDpi, oldDpi ); + HFONT hNewFont = CreateFontIndirect( &lf ); + if( hNewFont ) + { + SendMessage( hChild, WM_SETFONT, reinterpret_cast(hNewFont), TRUE ); + } + } + } + + hChild = GetWindow( hChild, GW_HWNDNEXT ); + } +} + +//---------------------------------------------------------------------------- +// +// HandleDialogDpiChange +// +// Handles WM_DPICHANGED message for dialogs. Call this from the dialog's +// WndProc when WM_DPICHANGED is received. +// +//---------------------------------------------------------------------------- +void HandleDialogDpiChange( HWND hDlg, WPARAM wParam, LPARAM lParam, UINT& currentDpi ) +{ + UINT newDpi = HIWORD( wParam ); + if( newDpi != currentDpi && newDpi != 0 ) + { + const RECT* pSuggestedRect = reinterpret_cast(lParam); + + // Scale the dialog controls from the current DPI to the new DPI + ScaleDialogForDpi( hDlg, newDpi, currentDpi ); + + // Move and resize the dialog to the suggested rectangle + SetWindowPos( hDlg, nullptr, + pSuggestedRect->left, + pSuggestedRect->top, + pSuggestedRect->right - pSuggestedRect->left, + pSuggestedRect->bottom - pSuggestedRect->top, + SWP_NOZORDER | SWP_NOACTIVATE ); + + currentDpi = newDpi; + } +} diff --git a/src/modules/ZoomIt/ZoomIt/Utility.h b/src/modules/ZoomIt/ZoomIt/Utility.h index a78ecdf14e..75a8142b46 100644 --- a/src/modules/ZoomIt/ZoomIt/Utility.h +++ b/src/modules/ZoomIt/ZoomIt/Utility.h @@ -9,6 +9,10 @@ #pragma once #include "pch.h" +#include + +// DPI baseline for scaling calculations (dialog units are designed at 96 DPI) +constexpr UINT DPI_BASELINE = USER_DEFAULT_SCREEN_DPI; RECT ForceRectInBounds( RECT rect, const RECT& bounds ); UINT GetDpiForWindowHelper( HWND window ); @@ -16,3 +20,86 @@ RECT GetMonitorRectFromCursor(); RECT RectFromPointsMinSize( POINT a, POINT b, LONG minSize ); int ScaleForDpi( int value, UINT dpi ); POINT ScalePointInRects( POINT point, const RECT& source, const RECT& target ); + +// Dialog DPI scaling functions +void ScaleDialogForDpi( HWND hDlg, UINT newDpi, UINT oldDpi = DPI_BASELINE ); +void ScaleChildControlsForDpi( HWND hParent, UINT newDpi, UINT oldDpi = DPI_BASELINE ); +void HandleDialogDpiChange( HWND hDlg, WPARAM wParam, LPARAM lParam, UINT& currentDpi ); + +//---------------------------------------------------------------------------- +// Dark Mode Support +//---------------------------------------------------------------------------- + +// Dark mode colors +namespace DarkMode +{ + // Background colors + constexpr COLORREF BackgroundColor = RGB(32, 32, 32); + constexpr COLORREF SurfaceColor = RGB(45, 45, 48); + constexpr COLORREF ControlColor = RGB(51, 51, 55); + + // Text colors + constexpr COLORREF TextColor = RGB(200, 200, 200); + constexpr COLORREF DisabledTextColor = RGB(120, 120, 120); + constexpr COLORREF LinkColor = RGB(86, 156, 214); + + // Border/accent colors + constexpr COLORREF BorderColor = RGB(67, 67, 70); + constexpr COLORREF AccentColor = RGB(0, 120, 215); + constexpr COLORREF HoverColor = RGB(62, 62, 66); + + // Light mode colors for contrast + constexpr COLORREF LightBackgroundColor = RGB(255, 255, 255); + constexpr COLORREF LightTextColor = RGB(0, 0, 0); +} + +// Check if system dark mode is enabled +bool IsDarkModeEnabled(); + +// Refresh dark mode state (call when WM_SETTINGCHANGE received) +void RefreshDarkModeState(); + +// Enable dark mode title bar for a window +void SetDarkModeForWindow(HWND hWnd, bool enable); + +// Apply dark mode to a dialog and enable dark title bar +void ApplyDarkModeToDialog(HWND hDlg); + +// Get the appropriate background brush for dark/light mode +HBRUSH GetDarkModeBrush(); +HBRUSH GetDarkModeControlBrush(); +HBRUSH GetDarkModeSurfaceBrush(); + +// Handle WM_CTLCOLOR* messages for dark mode +// Returns the brush to use, or nullptr if default handling should be used +HBRUSH HandleDarkModeCtlColor(HDC hdc, HWND hCtrl, UINT message); + +// Apply dark mode theme to a popup menu +void ApplyDarkModeToMenu(HMENU hMenu); + +// Force redraw of a window and all its children for theme change +void RefreshWindowTheme(HWND hWnd); + +// Cleanup dark mode resources (call at app exit) +void CleanupDarkModeResources(); + +// Initialize dark mode support early in app startup (call before creating windows) +void InitializeDarkMode(); + +// Subclass procedure for hotkey controls - needs to be accessible from Utility.cpp +LRESULT CALLBACK HotkeyControlSubclassProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam, UINT_PTR uIdSubclass, DWORD_PTR dwRefData); + +// Subclass procedure for checkbox controls - needs to be accessible from Utility.cpp +LRESULT CALLBACK CheckboxSubclassProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam, UINT_PTR uIdSubclass, DWORD_PTR dwRefData); + +// Subclass procedure for edit controls - needs to be accessible from Utility.cpp +LRESULT CALLBACK EditControlSubclassProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam, UINT_PTR uIdSubclass, DWORD_PTR dwRefData); + +// Subclass procedure for group box controls - needs to be accessible from Utility.cpp +LRESULT CALLBACK GroupBoxSubclassProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam, UINT_PTR uIdSubclass, DWORD_PTR dwRefData); + +// Subclass procedure for slider/trackbar controls - needs to be accessible from Utility.cpp +LRESULT CALLBACK SliderSubclassProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam, UINT_PTR uIdSubclass, DWORD_PTR dwRefData); + +// Subclass procedure for static text controls - needs to be accessible from Utility.cpp +LRESULT CALLBACK StaticTextSubclassProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam, UINT_PTR uIdSubclass, DWORD_PTR dwRefData); diff --git a/src/modules/ZoomIt/ZoomIt/VideoRecordingSession.cpp b/src/modules/ZoomIt/ZoomIt/VideoRecordingSession.cpp index 086c8bfb2a..d8c465bea4 100644 --- a/src/modules/ZoomIt/ZoomIt/VideoRecordingSession.cpp +++ b/src/modules/ZoomIt/ZoomIt/VideoRecordingSession.cpp @@ -9,8 +9,26 @@ #include "pch.h" #include "VideoRecordingSession.h" #include "CaptureFrameWait.h" +#include "Utility.h" +#include +#include +#include +#include +#include // For SHCreateStreamOnFileEx +#include // For timeBeginPeriod/timeEndPeriod + +#pragma comment(lib, "shlwapi.lib") +#pragma comment(lib, "winmm.lib") extern DWORD g_RecordScaling; +extern DWORD g_TrimDialogWidth; +extern DWORD g_TrimDialogHeight; +extern DWORD g_TrimDialogVolume; +extern class ClassRegistry reg; +extern REG_SETTING RegSettings[]; +extern HINSTANCE g_hInstance; + +HWND hDlgTrimDialog = nullptr; namespace winrt { @@ -19,11 +37,15 @@ namespace winrt using namespace Windows::Graphics::Capture; using namespace Windows::Graphics::DirectX; using namespace Windows::Graphics::DirectX::Direct3D11; + using namespace Windows::Graphics::Imaging; using namespace Windows::Storage; using namespace Windows::UI::Composition; using namespace Windows::Media::Core; using namespace Windows::Media::Transcoding; using namespace Windows::Media::MediaProperties; + using namespace Windows::Media::Editing; + using namespace Windows::Media::Playback; + using namespace Windows::Storage::FileProperties; } namespace util @@ -32,6 +54,10 @@ namespace util } const float CLEAR_COLOR[] = { 0.0f, 0.0f, 0.0f, 1.0f }; +constexpr UINT kGifDefaultDelayCs = 10; // 100ms (~10 FPS) when metadata delay is missing +constexpr UINT kGifMinDelayCs = 2; // 20ms minimum; browsers treat <2cs as 10cs (100ms) +constexpr UINT kGifBrowserFixupThreshold = 2; // Delays < this are treated as 10cs by browsers +constexpr UINT kGifMaxPreviewDimension = 1280; // cap decoded GIF preview size to keep playback smooth int32_t EnsureEven(int32_t value) { @@ -45,6 +71,784 @@ int32_t EnsureEven(int32_t value) } } +static bool IsGifPath(const std::wstring& path) +{ + try + { + const auto ext = std::filesystem::path(path).extension().wstring(); + return _wcsicmp(ext.c_str(), L".gif") == 0; + } + catch (...) + { + return false; + } +} + +static void CleanupGifFrames(VideoRecordingSession::TrimDialogData* pData) +{ + if (!pData) + { + return; + } + + for (auto& frame : pData->gifFrames) + { + if (frame.hBitmap) + { + DeleteObject(frame.hBitmap); + frame.hBitmap = nullptr; + } + } + pData->gifFrames.clear(); +} + +static size_t FindGifFrameIndex(const std::vector& frames, int64_t ticks) +{ + if (frames.empty()) + { + return 0; + } + + // Linear scan is fine for typical GIF counts; keeps logic simple and predictable + for (size_t i = 0; i < frames.size(); ++i) + { + const auto start = frames[i].start.count(); + const auto end = start + frames[i].duration.count(); + if (ticks >= start && ticks < end) + { + return i; + } + } + + // If we fall through, clamp to last frame + return frames.size() - 1; +} + +static bool LoadGifFrames(const std::wstring& gifPath, VideoRecordingSession::TrimDialogData* pData) +{ + OutputDebugStringW((L"[GIF Trim] LoadGifFrames called for: " + gifPath + L"\n").c_str()); + + if (!pData) + { + OutputDebugStringW(L"[GIF Trim] pData is null\n"); + return false; + } + + try + { + CleanupGifFrames(pData); + + winrt::com_ptr factory; + HRESULT hrFactory = CoCreateInstance(CLSID_WICImagingFactory, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(factory.put())); + if (FAILED(hrFactory)) + { + OutputDebugStringW((L"[GIF Trim] CoCreateInstance WICImagingFactory failed hr=0x" + std::to_wstring(hrFactory) + L"\n").c_str()); + return false; + } + + winrt::com_ptr decoder; + + auto logHr = [&](const wchar_t* step, HRESULT hr) + { + wchar_t buf[512]{}; + swprintf_s(buf, L"[GIF Trim] %s failed hr=0x%08X path=%s\n", step, static_cast(hr), gifPath.c_str()); + OutputDebugStringW(buf); + }; + + auto tryCreateDecoder = [&]() -> bool + { + OutputDebugStringW(L"[GIF Trim] Trying CreateDecoderFromFilename...\n"); + HRESULT hr = factory->CreateDecoderFromFilename(gifPath.c_str(), nullptr, GENERIC_READ, WICDecodeMetadataCacheOnLoad, decoder.put()); + if (SUCCEEDED(hr)) + { + OutputDebugStringW(L"[GIF Trim] CreateDecoderFromFilename succeeded\n"); + return true; + } + + logHr(L"CreateDecoderFromFilename", hr); + + // Fallback: try opening with FILE_SHARE_READ | FILE_SHARE_WRITE to handle locked files + OutputDebugStringW(L"[GIF Trim] Trying CreateStreamOnFile fallback...\n"); + HANDLE hFile = CreateFileW(gifPath.c_str(), GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); + if (hFile != INVALID_HANDLE_VALUE) + { + winrt::com_ptr fileStream; + // Create an IStream over the file handle using SHCreateStreamOnFileEx + CloseHandle(hFile); + hr = SHCreateStreamOnFileEx(gifPath.c_str(), STGM_READ | STGM_SHARE_DENY_NONE, 0, FALSE, nullptr, fileStream.put()); + if (SUCCEEDED(hr) && fileStream) + { + hr = factory->CreateDecoderFromStream(fileStream.get(), nullptr, WICDecodeMetadataCacheOnLoad, decoder.put()); + if (SUCCEEDED(hr)) + { + OutputDebugStringW(L"[GIF Trim] CreateDecoderFromStream (SHCreateStreamOnFileEx) succeeded\n"); + return true; + } + logHr(L"CreateDecoderFromStream(SHCreateStreamOnFileEx)", hr); + } + else + { + logHr(L"SHCreateStreamOnFileEx", hr); + } + } + + return false; + }; + + auto tryCopyAndDecode = [&]() -> bool + { + OutputDebugStringW(L"[GIF Trim] Trying temp file copy fallback...\n"); + // Copy file to temp using Win32 APIs (no WinRT async) + wchar_t tempDir[MAX_PATH]; + if (GetTempPathW(MAX_PATH, tempDir) == 0) + { + return false; + } + + std::wstring tempPath = std::wstring(tempDir) + L"ZoomIt\\"; + CreateDirectoryW(tempPath.c_str(), nullptr); + + std::wstring tempName = L"gif_trim_cache_" + std::to_wstring(GetTickCount64()) + L".gif"; + tempPath += tempName; + + if (!CopyFileW(gifPath.c_str(), tempPath.c_str(), FALSE)) + { + logHr(L"CopyFileW", HRESULT_FROM_WIN32(GetLastError())); + return false; + } + + HRESULT hr = factory->CreateDecoderFromFilename(tempPath.c_str(), nullptr, GENERIC_READ, WICDecodeMetadataCacheOnLoad, decoder.put()); + if (SUCCEEDED(hr)) + { + OutputDebugStringW(L"[GIF Trim] CreateDecoderFromFilename(temp copy) succeeded\n"); + return true; + } + logHr(L"CreateDecoderFromFilename(temp copy)", hr); + + // Clean up temp file on failure + DeleteFileW(tempPath.c_str()); + return false; + }; + + if (!tryCreateDecoder()) + { + if (!tryCopyAndDecode()) + { + return false; + } + } + + UINT frameCount = 0; + if (FAILED(decoder->GetFrameCount(&frameCount)) || frameCount == 0) + { + return false; + } + + int64_t cumulativeTicks = 0; + UINT frameWidth = 0; + UINT frameHeight = 0; + + for (UINT i = 0; i < frameCount; ++i) + { + winrt::com_ptr frame; + if (FAILED(decoder->GetFrame(i, frame.put()))) + { + continue; + } + + if (i == 0) + { + frame->GetSize(&frameWidth, &frameHeight); + } + + UINT delayCs = kGifDefaultDelayCs; + try + { + winrt::com_ptr metadata; + if (SUCCEEDED(frame->GetMetadataQueryReader(metadata.put())) && metadata) + { + PROPVARIANT prop{}; + PropVariantInit(&prop); + if (SUCCEEDED(metadata->GetMetadataByName(L"/grctlext/Delay", &prop))) + { + if (prop.vt == VT_UI2) + { + delayCs = prop.uiVal; + } + else if (prop.vt == VT_UI1) + { + delayCs = prop.bVal; + } + } + PropVariantClear(&prop); + } + } + catch (...) + { + // Keep fallback delay + } + + if (delayCs == 0) + { + // GIF spec: delay of 0 means "as fast as possible"; browsers use ~10ms + delayCs = kGifDefaultDelayCs; + } + else if (delayCs < kGifBrowserFixupThreshold) + { + // Browsers treat delays < 2cs (20ms) as 10cs (100ms) to prevent CPU-hogging GIFs + delayCs = kGifDefaultDelayCs; + } + + // Log the first few frame delays for debugging + if (i < 3) + { + OutputDebugStringW((L"[GIF Trim] Frame " + std::to_wstring(i) + L" delay: " + std::to_wstring(delayCs) + L" cs (" + std::to_wstring(delayCs * 10) + L" ms)\n").c_str()); + } + + // Respect a max preview size to avoid huge allocations on large GIFs + UINT targetWidth = frameWidth; + UINT targetHeight = frameHeight; + if (targetWidth > kGifMaxPreviewDimension || targetHeight > kGifMaxPreviewDimension) + { + const double scaleX = static_cast(kGifMaxPreviewDimension) / static_cast(targetWidth); + const double scaleY = static_cast(kGifMaxPreviewDimension) / static_cast(targetHeight); + const double scale = (std::min)(scaleX, scaleY); + targetWidth = static_cast(std::lround(static_cast(targetWidth) * scale)); + targetHeight = static_cast(std::lround(static_cast(targetHeight) * scale)); + targetWidth = (std::max)(1u, targetWidth); + targetHeight = (std::max)(1u, targetHeight); + } + + winrt::com_ptr source = frame; + if (targetWidth != frameWidth || targetHeight != frameHeight) + { + winrt::com_ptr scaler; + if (SUCCEEDED(factory->CreateBitmapScaler(scaler.put()))) + { + if (SUCCEEDED(scaler->Initialize(frame.get(), targetWidth, targetHeight, WICBitmapInterpolationModeFant))) + { + source = scaler; + } + } + } + + winrt::com_ptr converter; + if (FAILED(factory->CreateFormatConverter(converter.put()))) + { + continue; + } + + if (FAILED(converter->Initialize(source.get(), GUID_WICPixelFormat32bppPBGRA, WICBitmapDitherTypeNone, nullptr, 0.0, WICBitmapPaletteTypeCustom))) + { + continue; + } + + UINT convertedWidth = 0; + UINT convertedHeight = 0; + converter->GetSize(&convertedWidth, &convertedHeight); + if (convertedWidth == 0 || convertedHeight == 0) + { + continue; + } + + const UINT stride = convertedWidth * 4; + std::vector buffer(static_cast(stride) * convertedHeight); + if (FAILED(converter->CopyPixels(nullptr, stride, static_cast(buffer.size()), buffer.data()))) + { + continue; + } + + BITMAPINFO bmi{}; + bmi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); + bmi.bmiHeader.biWidth = static_cast(convertedWidth); + bmi.bmiHeader.biHeight = -static_cast(convertedHeight); + bmi.bmiHeader.biPlanes = 1; + bmi.bmiHeader.biBitCount = 32; + bmi.bmiHeader.biCompression = BI_RGB; + + void* bits = nullptr; + HDC hdcScreen = GetDC(nullptr); + HBITMAP hBitmap = CreateDIBSection(hdcScreen, &bmi, DIB_RGB_COLORS, &bits, nullptr, 0); + ReleaseDC(nullptr, hdcScreen); + + if (!hBitmap || !bits) + { + if (hBitmap) + { + DeleteObject(hBitmap); + } + continue; + } + + for (UINT row = 0; row < convertedHeight; ++row) + { + memcpy(static_cast(bits) + static_cast(row) * stride, + buffer.data() + static_cast(row) * stride, + stride); + } + + VideoRecordingSession::TrimDialogData::GifFrame gifFrame; + gifFrame.hBitmap = hBitmap; + gifFrame.start = winrt::TimeSpan{ cumulativeTicks }; + gifFrame.duration = winrt::TimeSpan{ static_cast(delayCs) * 100'000 }; // centiseconds to 100ns + gifFrame.width = convertedWidth; + gifFrame.height = convertedHeight; + + cumulativeTicks += gifFrame.duration.count(); + pData->gifFrames.push_back(gifFrame); + } + + if (pData->gifFrames.empty()) + { + OutputDebugStringW(L"[GIF Trim] No frames loaded\n"); + return false; + } + + const auto& lastFrame = pData->gifFrames.back(); + pData->videoDuration = winrt::TimeSpan{ lastFrame.start.count() + lastFrame.duration.count() }; + pData->trimEnd = pData->videoDuration; + pData->gifFramesLoaded = true; + pData->gifLastFrameIndex = 0; + + OutputDebugStringW((L"[GIF Trim] Successfully loaded " + std::to_wstring(pData->gifFrames.size()) + L" frames\n").c_str()); + return true; + } + catch (const winrt::hresult_error& e) + { + OutputDebugStringW((L"[GIF Trim] Exception in LoadGifFrames: " + e.message() + L"\n").c_str()); + return false; + } + catch (const std::exception& e) + { + OutputDebugStringA("[GIF Trim] std::exception in LoadGifFrames: "); + OutputDebugStringA(e.what()); + OutputDebugStringA("\n"); + return false; + } + catch (...) + { + OutputDebugStringW(L"[GIF Trim] Unknown exception in LoadGifFrames\n"); + return false; + } +} + +namespace +{ + struct __declspec(uuid("5b0d3235-4dba-4d44-8657-1f1d0f83e9a3")) IMemoryBufferByteAccess : IUnknown + { + virtual HRESULT STDMETHODCALLTYPE GetBuffer(BYTE** value, UINT32* capacity) = 0; + }; + + constexpr int kTimelinePadding = 12; + constexpr int kTimelineTrackHeight = 24; + constexpr int kTimelineTrackTopOffset = 18; + constexpr int kTimelineHandleHalfWidth = 5; + constexpr int kTimelineHandleHeight = 40; + constexpr int kTimelineHandleHitRadius = 18; + constexpr int64_t kJogStepTicks = 20'000'000; // 2 seconds (or 1s for short videos) + constexpr int64_t kPreviewMinDeltaTicks = 2'000'000; // 20ms between thumbnails while playing + constexpr UINT32 kPreviewRequestWidthPlaying = 320; + constexpr UINT32 kPreviewRequestHeightPlaying = 180; + constexpr int64_t kTicksPerMicrosecond = 10; // 100ns units per microsecond + constexpr int64_t kPlaybackSyncIntervalMs = 40; // refresh baseline frequently for smoother prediction + constexpr int64_t kPlaybackDriftCheckMs = 40; // sample MediaPlayer at least every 40ms (overridden to every tick currently) + constexpr int64_t kPlaybackDriftSnapTicks = 2'000'000; // snap if drift exceeds 200ms + constexpr int kPlaybackDriftBlendNumerator = 1; // blend 20% toward real position + constexpr int kPlaybackDriftBlendDenominator = 5; + constexpr UINT WMU_PREVIEW_READY = WM_USER + 1; + constexpr UINT WMU_PREVIEW_SCHEDULED = WM_USER + 2; + constexpr UINT WMU_DURATION_CHANGED = WM_USER + 3; + constexpr UINT WMU_PLAYBACK_POSITION = WM_USER + 4; + constexpr UINT WMU_PLAYBACK_STOP = WM_USER + 5; + constexpr UINT_PTR kPreviewDebounceTimerId = 100; + constexpr UINT kPreviewDebounceDelayMs = 50; // Debounce delay for preview updates during dragging + + std::atomic g_highResTimerRefs{ 0 }; + + void AcquireHighResTimer() + { + if (g_highResTimerRefs.fetch_add(1, std::memory_order_relaxed) == 0) + { + timeBeginPeriod(1); + } + } + + void ReleaseHighResTimer() + { + const int prev = g_highResTimerRefs.fetch_sub(1, std::memory_order_relaxed); + if (prev == 1) + { + timeEndPeriod(1); + } + } + + bool EnsurePlaybackDevice(VideoRecordingSession::TrimDialogData* pData) + { + if (!pData) + { + return false; + } + + if (pData->previewD3DDevice && pData->previewD3DContext) + { + return true; + } + + UINT creationFlags = D3D11_CREATE_DEVICE_BGRA_SUPPORT; +#if defined(_DEBUG) + creationFlags |= D3D11_CREATE_DEVICE_DEBUG; +#endif + + D3D_FEATURE_LEVEL levels[] = { D3D_FEATURE_LEVEL_11_1, D3D_FEATURE_LEVEL_11_0, D3D_FEATURE_LEVEL_10_1, D3D_FEATURE_LEVEL_10_0 }; + D3D_FEATURE_LEVEL levelCreated = D3D_FEATURE_LEVEL_11_0; + + winrt::com_ptr device; + winrt::com_ptr context; + if (SUCCEEDED(D3D11CreateDevice( + nullptr, + D3D_DRIVER_TYPE_HARDWARE, + nullptr, + creationFlags, + levels, + ARRAYSIZE(levels), + D3D11_SDK_VERSION, + device.put(), + &levelCreated, + context.put()))) + { + pData->previewD3DDevice = device; + pData->previewD3DContext = context; + return true; + } + + return false; + } + + bool EnsureFrameTextures(VideoRecordingSession::TrimDialogData* pData, UINT width, UINT height) + { + if (!pData || !pData->previewD3DDevice) + { + return false; + } + + auto recreate = [&]() + { + pData->previewFrameTexture = nullptr; + pData->previewFrameStaging = nullptr; + + D3D11_TEXTURE2D_DESC desc{}; + desc.Width = width; + desc.Height = height; + desc.MipLevels = 1; + desc.ArraySize = 1; + desc.Format = DXGI_FORMAT_B8G8R8A8_UNORM; + desc.SampleDesc.Count = 1; + desc.Usage = D3D11_USAGE_DEFAULT; + desc.BindFlags = D3D11_BIND_RENDER_TARGET | D3D11_BIND_SHADER_RESOURCE; + + winrt::com_ptr frameTex; + if (FAILED(pData->previewD3DDevice->CreateTexture2D(&desc, nullptr, frameTex.put()))) + { + return false; + } + + desc.BindFlags = 0; + desc.CPUAccessFlags = D3D11_CPU_ACCESS_READ; + desc.Usage = D3D11_USAGE_STAGING; + + winrt::com_ptr staging; + if (FAILED(pData->previewD3DDevice->CreateTexture2D(&desc, nullptr, staging.put()))) + { + return false; + } + + pData->previewFrameTexture = frameTex; + pData->previewFrameStaging = staging; + return true; + }; + + if (!pData->previewFrameTexture || !pData->previewFrameStaging) + { + return recreate(); + } + + D3D11_TEXTURE2D_DESC existing{}; + pData->previewFrameTexture->GetDesc(&existing); + if (existing.Width != width || existing.Height != height) + { + return recreate(); + } + + return true; + } + + void CenterTrimDialog(HWND hDlg) + { + if (!hDlg) + { + return; + } + + RECT rcDlg{}; + if (!GetWindowRect(hDlg, &rcDlg)) + { + return; + } + + const int dlgWidth = rcDlg.right - rcDlg.left; + const int dlgHeight = rcDlg.bottom - rcDlg.top; + + // Always center on the monitor containing the dialog, not the parent window + RECT rcTarget{}; + HMONITOR monitor = MonitorFromWindow(hDlg, MONITOR_DEFAULTTONEAREST); + MONITORINFO mi{ sizeof(mi) }; + if (GetMonitorInfo(monitor, &mi)) + { + rcTarget = mi.rcWork; + } + else + { + rcTarget.left = 0; + rcTarget.top = 0; + rcTarget.right = GetSystemMetrics(SM_CXSCREEN); + rcTarget.bottom = GetSystemMetrics(SM_CYSCREEN); + } + + const int targetWidth = rcTarget.right - rcTarget.left; + const int targetHeight = rcTarget.bottom - rcTarget.top; + + int newX = rcTarget.left + (targetWidth - dlgWidth) / 2; + int newY = rcTarget.top + (targetHeight - dlgHeight) / 2; + + if (dlgWidth >= targetWidth) + { + newX = rcTarget.left; + } + else + { + newX = static_cast((std::clamp)(static_cast(newX), rcTarget.left, rcTarget.right - dlgWidth)); + } + + if (dlgHeight >= targetHeight) + { + newY = rcTarget.top; + } + else + { + newY = static_cast((std::clamp)(static_cast(newY), rcTarget.top, rcTarget.bottom - dlgHeight)); + } + + SetWindowPos(hDlg, nullptr, newX, newY, 0, 0, SWP_NOACTIVATE | SWP_NOSIZE | SWP_NOZORDER); + } + + std::wstring FormatTrimTime(const winrt::TimeSpan& value, bool includeMilliseconds) + { + const int64_t ticks = (std::max)(value.count(), int64_t{ 0 }); + const int64_t totalMilliseconds = ticks / 10000LL; + const int milliseconds = static_cast(totalMilliseconds % 1000); + const int64_t totalSeconds = totalMilliseconds / 1000LL; + const int seconds = static_cast(totalSeconds % 60LL); + const int64_t totalMinutes = totalSeconds / 60LL; + const int minutes = static_cast(totalMinutes % 60LL); + const int hours = static_cast(totalMinutes / 60LL); + + wchar_t buffer[32]{}; + if (hours > 0) + { + swprintf_s(buffer, L"%d:%02d:%02d", hours, minutes, seconds); + } + else + { + swprintf_s(buffer, L"%02d:%02d", minutes, seconds); + } + + if (!includeMilliseconds) + { + return std::wstring(buffer); + } + + wchar_t msBuffer[8]{}; + swprintf_s(msBuffer, L".%03d", milliseconds); + return std::wstring(buffer) + msBuffer; + } + + std::wstring FormatDurationString(const winrt::TimeSpan& duration) + { + return L"Selection: " + FormatTrimTime(duration, true); + } + + void SetTimeText(HWND hDlg, int controlId, const winrt::TimeSpan& value, bool includeMilliseconds) + { + const std::wstring formatted = FormatTrimTime(value, includeMilliseconds); + // Only update if the text has changed to prevent flashing + wchar_t currentText[64] = {}; + GetDlgItemText(hDlg, controlId, currentText, _countof(currentText)); + if (formatted != currentText) + { + SetDlgItemText(hDlg, controlId, formatted.c_str()); + } + } + + int TimelineTimeToClientX(const VideoRecordingSession::TrimDialogData* pData, winrt::TimeSpan value, int clientWidth, UINT dpi = DPI_BASELINE) + { + const int padding = ScaleForDpi(kTimelinePadding, dpi); + const int trackWidth = (std::max)(clientWidth - padding * 2, 1); + return padding + pData->TimeToPixel(value, trackWidth); + } + + winrt::TimeSpan TimelinePixelToTime(const VideoRecordingSession::TrimDialogData* pData, int x, int clientWidth, UINT dpi = DPI_BASELINE) + { + const int padding = ScaleForDpi(kTimelinePadding, dpi); + const int trackWidth = (std::max)(clientWidth - padding * 2, 1); + const int localX = std::clamp(x - padding, 0, trackWidth); + return pData->PixelToTime(localX, trackWidth); + } + + void UpdateDurationDisplay(HWND hDlg, VideoRecordingSession::TrimDialogData* pData) + { + if (!pData || !hDlg) + { + return; + } + + const int64_t selectionTicks = (std::max)(pData->trimEnd.count() - pData->trimStart.count(), int64_t{ 0 }); + const std::wstring durationText = FormatDurationString(winrt::TimeSpan{ selectionTicks }); + // Only update if the text has changed to prevent flashing + wchar_t currentText[64] = {}; + GetDlgItemText(hDlg, IDC_TRIM_DURATION_LABEL, currentText, _countof(currentText)); + if (durationText != currentText) + { + SetDlgItemText(hDlg, IDC_TRIM_DURATION_LABEL, durationText.c_str()); + } + + // Enable OK when trimming is active (even if unchanged since dialog opened), + // or when the user changed the selection (including reverting to full length). + const bool trimChanged = (pData->trimStart.count() != pData->originalTrimStart.count()) || + (pData->trimEnd.count() != pData->originalTrimEnd.count()); + const bool trimIsActive = (pData->trimStart.count() > 0) || + (pData->videoDuration.count() > 0 && pData->trimEnd.count() < pData->videoDuration.count()); + EnableWindow(GetDlgItem(hDlg, IDOK), trimChanged || trimIsActive); + } + + RECT GetTimelineTrackRect(const RECT& clientRect, UINT dpi) + { + const int padding = ScaleForDpi(kTimelinePadding, dpi); + const int trackOffset = ScaleForDpi(kTimelineTrackTopOffset, dpi); + const int trackHeight = ScaleForDpi(kTimelineTrackHeight, dpi); + const int trackLeft = clientRect.left + padding; + const int trackRight = clientRect.right - padding; + const int trackTop = clientRect.top + trackOffset; + const int trackBottom = trackTop + trackHeight; + RECT track{ trackLeft, trackTop, trackRight, trackBottom }; + return track; + } + + RECT GetPlayheadBoundsRect(const RECT& clientRect, int x, UINT dpi) + { + RECT track = GetTimelineTrackRect(clientRect, dpi); + const int lineThick = ScaleForDpi(3, dpi); + const int topExt = ScaleForDpi(12, dpi); + const int botExt = ScaleForDpi(22, dpi); + const int circleR = ScaleForDpi(6, dpi); + const int circleBotOff = ScaleForDpi(12, dpi); + const int circleBotEnd = ScaleForDpi(24, dpi); + RECT lineRect{ x - lineThick + 1, track.top - topExt, x + lineThick, track.bottom + botExt }; + RECT circleRect{ x - circleR, track.bottom + circleBotOff, x + circleR, track.bottom + circleBotEnd }; + RECT combined{}; + UnionRect(&combined, &lineRect, &circleRect); + return combined; + } + + void InvalidatePlayheadRegion(HWND hTimeline, const RECT& clientRect, int previousX, int newX, UINT dpi) + { + if (!hTimeline) + { + return; + } + + RECT invalidRect{}; + bool hasRect = false; + + if (previousX >= 0) + { + RECT oldRect = GetPlayheadBoundsRect(clientRect, previousX, dpi); + invalidRect = oldRect; + hasRect = true; + } + + if (newX >= 0) + { + RECT newRect = GetPlayheadBoundsRect(clientRect, newX, dpi); + if (hasRect) + { + RECT unionRect{}; + UnionRect(&unionRect, &invalidRect, &newRect); + invalidRect = unionRect; + } + else + { + invalidRect = newRect; + hasRect = true; + } + } + + if (hasRect) + { + InflateRect(&invalidRect, 2, 2); + InvalidateRect(hTimeline, &invalidRect, FALSE); + } + } +} + +static int64_t SteadyClockMicros() +{ + return std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count(); +} + +static void ResetSmoothPlayback(VideoRecordingSession::TrimDialogData* pData) +{ + if (!pData) + { + return; + } + + pData->smoothActive.store(false, std::memory_order_relaxed); + pData->smoothBaseTicks.store(0, std::memory_order_relaxed); + pData->smoothLastSyncMicroseconds.store(0, std::memory_order_relaxed); + pData->smoothHasNonZeroSample.store(false, std::memory_order_relaxed); +} + +static void LogSmoothingEvent(const wchar_t* label, int64_t predictedTicks, int64_t mediaTicks, int64_t driftTicks); + +static void SyncSmoothPlayback(VideoRecordingSession::TrimDialogData* pData, int64_t mediaTicks, int64_t /*minTicks*/, int64_t /*maxTicks*/) +{ + if (!pData) + { + return; + } + + const int64_t nowUs = SteadyClockMicros(); + pData->smoothBaseTicks.store(mediaTicks, std::memory_order_relaxed); + pData->smoothLastSyncMicroseconds.store(nowUs, std::memory_order_relaxed); + pData->smoothActive.store(true, std::memory_order_relaxed); + pData->smoothHasNonZeroSample.store(mediaTicks > 0, std::memory_order_relaxed); + + LogSmoothingEvent(L"setBase", mediaTicks, mediaTicks, 0); +} + +static void LogSmoothingEvent(const wchar_t* label, int64_t predictedTicks, int64_t mediaTicks, int64_t driftTicks) +{ + wchar_t buf[256]{}; + swprintf_s(buf, L"[TrimSmooth] %s pred=%lld media=%lld drift=%lld\n", + label ? label : L"", static_cast(predictedTicks), static_cast(mediaTicks), static_cast(driftTicks)); + OutputDebugStringW(buf); +} + +static void StopPlayback(HWND hDlg, VideoRecordingSession::TrimDialogData* pData, bool capturePosition = true); +static winrt::fire_and_forget StartPlaybackAsync(HWND hDlg, VideoRecordingSession::TrimDialogData* pData); + + //---------------------------------------------------------------------------- // // VideoRecordingSession::VideoRecordingSession @@ -56,6 +860,7 @@ VideoRecordingSession::VideoRecordingSession( RECT const cropRect, uint32_t frameRate, bool captureAudio, + bool captureSystemAudio, winrt::Streams::IRandomAccessStream const& stream) { m_device = device; @@ -134,13 +939,10 @@ VideoRecordingSession::VideoRecordingSession( video.PixelAspectRatio().Denominator(1); m_encodingProfile.Video(video); - // if audio capture, set up audio profile - if (captureAudio) - { - auto audio = m_encodingProfile.Audio(); - audio = winrt::AudioEncodingProperties::CreateAac(48000, 1, 16); - m_encodingProfile.Audio(audio); - } + // Always set up audio profile for loopback capture (stereo AAC) + auto audio = m_encodingProfile.Audio(); + audio = winrt::AudioEncodingProperties::CreateAac(48000, 2, 192000); + m_encodingProfile.Audio(audio); // Describe our input: uncompressed BGRA8 buffers auto properties = winrt::VideoEncodingProperties::CreateUncompressed( @@ -161,14 +963,8 @@ VideoRecordingSession::VideoRecordingSession( winrt::check_hresult(m_previewSwapChain->GetBuffer(0, winrt::guid_of(), backBuffer.put_void())); winrt::check_hresult(m_d3dDevice->CreateRenderTargetView(backBuffer.get(), nullptr, m_renderTargetView.put())); - if( captureAudio ) { - - m_audioGenerator = std::make_unique(); - } - else { - - m_audioGenerator = nullptr; - } + // Always create audio generator for loopback capture; captureAudio controls microphone + m_audioGenerator = std::make_unique(captureAudio, captureSystemAudio); } @@ -215,8 +1011,34 @@ winrt::IAsyncAction VideoRecordingSession::StartAsync() auto self = shared_from_this(); // Start encoding - auto transcode = co_await m_transcoder.PrepareMediaStreamSourceTranscodeAsync(m_streamSource, m_stream, m_encodingProfile); - co_await transcode.TranscodeAsync(); + // If the user stops recording immediately after starting, MediaTranscoder may fail + // with MF_E_NO_SAMPLE_PROCESSED (0xC00D4A44). Avoid surfacing this as an error. + if (m_closed.load()) + { + co_return; + } + + winrt::PrepareTranscodeResult transcode{ nullptr }; + try + { + transcode = co_await m_transcoder.PrepareMediaStreamSourceTranscodeAsync(m_streamSource, m_stream, m_encodingProfile); + + if (m_closed.load()) + { + co_return; + } + + co_await transcode.TranscodeAsync(); + } + catch (winrt::hresult_error const& error) + { + constexpr HRESULT MF_E_NO_SAMPLE_PROCESSED = static_cast(0xC00D4A44); + if (m_closed.load() || error.code() == MF_E_NO_SAMPLE_PROCESSED) + { + co_return; + } + throw; + } } co_return; } @@ -289,9 +1111,10 @@ std::shared_ptr VideoRecordingSession::Create( RECT const& crop, uint32_t frameRate, bool captureAudio, + bool captureSystemAudio, winrt::Streams::IRandomAccessStream const& stream) { - return std::shared_ptr(new VideoRecordingSession(device, item, crop, frameRate, captureAudio, stream)); + return std::shared_ptr(new VideoRecordingSession(device, item, crop, frameRate, captureAudio, captureSystemAudio, stream)); } //---------------------------------------------------------------------------- @@ -361,6 +1184,7 @@ void VideoRecordingSession::OnMediaStreamSourceSampleRequested( winrt::check_hresult(m_previewSwapChain->Present1(0, 0, &presentParameters)); auto sample = winrt::MediaStreamSample::CreateFromDirect3D11Surface(sampleSurface, timeStamp); + m_hasVideoSample.store(true); request.Sample(sample); } catch (winrt::hresult_error const& error) @@ -376,16 +1200,4110 @@ void VideoRecordingSession::OnMediaStreamSourceSampleRequested( request.Sample(nullptr); CloseInternal(); } - } + } else if (auto audioStreamDescriptor = streamDescriptor.try_as()) { - if (auto sample = m_audioGenerator->TryGetNextSample()) + try { - request.Sample(sample.value()); + if (auto sample = m_audioGenerator->TryGetNextSample()) + { + request.Sample(sample.value()); + } + else + { + request.Sample(nullptr); + } } - else + catch (winrt::hresult_error const& error) { + OutputDebugStringW(error.message().c_str()); request.Sample(nullptr); + CloseInternal(); + return; } } } + +//---------------------------------------------------------------------------- +// +// Custom file dialog events handler for Trim button +// +//---------------------------------------------------------------------------- +class CTrimFileDialogEvents : public IFileDialogEvents, public IFileDialogControlEvents +{ +private: + long m_cRef; + HWND m_hParent; + std::wstring m_videoPath; + std::wstring* m_pTrimmedPath; + winrt::TimeSpan* m_pTrimStart; + winrt::TimeSpan* m_pTrimEnd; + bool* m_pShouldTrim; + bool m_bIconSet; + +public: + CTrimFileDialogEvents(HWND hParent, const std::wstring& videoPath, + std::wstring* pTrimmedPath, winrt::TimeSpan* pTrimStart, + winrt::TimeSpan* pTrimEnd, bool* pShouldTrim) + : m_cRef(1), m_hParent(hParent), m_videoPath(videoPath), + m_pTrimmedPath(pTrimmedPath), m_pTrimStart(pTrimStart), + m_pTrimEnd(pTrimEnd), m_pShouldTrim(pShouldTrim), m_bIconSet(false) + { + } + + // IUnknown + IFACEMETHODIMP QueryInterface(REFIID riid, void** ppv) + { + static const QITAB qit[] = { + QITABENT(CTrimFileDialogEvents, IFileDialogEvents), + QITABENT(CTrimFileDialogEvents, IFileDialogControlEvents), + { 0 }, + }; + return QISearch(this, qit, riid, ppv); + } + + IFACEMETHODIMP_(ULONG) AddRef() + { + return InterlockedIncrement(&m_cRef); + } + + IFACEMETHODIMP_(ULONG) Release() + { + long cRef = InterlockedDecrement(&m_cRef); + if (!cRef) + delete this; + return cRef; + } + + // IFileDialogEvents + IFACEMETHODIMP OnFileOk(IFileDialog*) { return S_OK; } + + IFACEMETHODIMP OnFolderChange(IFileDialog* pfd) + { + // Set the ZoomIt icon on the save dialog (only once) + if (!m_bIconSet) + { + m_bIconSet = true; + wil::com_ptr pOleWnd; + if (SUCCEEDED(pfd->QueryInterface(IID_PPV_ARGS(&pOleWnd)))) + { + HWND hDlg = nullptr; + if (SUCCEEDED(pOleWnd->GetWindow(&hDlg)) && hDlg) + { + HICON hIcon = LoadIcon(g_hInstance, L"APPICON"); + if (hIcon) + { + SendMessage(hDlg, WM_SETICON, ICON_BIG, reinterpret_cast(hIcon)); + SendMessage(hDlg, WM_SETICON, ICON_SMALL, reinterpret_cast(hIcon)); + } + + // Make dialog appear in taskbar + LONG_PTR exStyle = GetWindowLongPtr(hDlg, GWL_EXSTYLE); + SetWindowLongPtr(hDlg, GWL_EXSTYLE, exStyle | WS_EX_APPWINDOW); + } + } + } + return S_OK; + } + + IFACEMETHODIMP OnFolderChanging(IFileDialog*, IShellItem*) { return S_OK; } + IFACEMETHODIMP OnSelectionChange(IFileDialog*) { return S_OK; } + IFACEMETHODIMP OnShareViolation(IFileDialog*, IShellItem*, FDE_SHAREVIOLATION_RESPONSE*) { return S_OK; } + IFACEMETHODIMP OnTypeChange(IFileDialog*) { return S_OK; } + IFACEMETHODIMP OnOverwrite(IFileDialog*, IShellItem*, FDE_OVERWRITE_RESPONSE*) { return S_OK; } + + // IFileDialogControlEvents + IFACEMETHODIMP OnItemSelected(IFileDialogCustomize*, DWORD, DWORD) { return S_OK; } + IFACEMETHODIMP OnCheckButtonToggled(IFileDialogCustomize*, DWORD, BOOL) { return S_OK; } + IFACEMETHODIMP OnControlActivating(IFileDialogCustomize*, DWORD) { return S_OK; } + + IFACEMETHODIMP OnButtonClicked(IFileDialogCustomize* pfdc, DWORD dwIDCtl) + { + if (dwIDCtl == 2000) // Trim button ID + { + try + { + // Get the file dialog's window handle to make trim dialog modal to it + wil::com_ptr pfd; + HWND hFileDlg = nullptr; + if (SUCCEEDED(pfdc->QueryInterface(IID_PPV_ARGS(&pfd)))) + { + wil::com_ptr pOleWnd; + if (SUCCEEDED(pfd->QueryInterface(IID_PPV_ARGS(&pOleWnd)))) + { + pOleWnd->GetWindow(&hFileDlg); + } + } + + // Use file dialog window as parent if found + HWND hParent = hFileDlg ? hFileDlg : m_hParent; + + auto trimResult = VideoRecordingSession::ShowTrimDialog(hParent, m_videoPath, *m_pTrimStart, *m_pTrimEnd); + if (trimResult == IDOK) + { + *m_pShouldTrim = true; + } + else if( trimResult == IDCANCEL ) + { + // Cancel should reset to the default selection (fresh state) and + // disable trimming for the eventual save. + *m_pTrimStart = winrt::TimeSpan{ 0 }; + *m_pTrimEnd = winrt::TimeSpan{ 0 }; + *m_pShouldTrim = false; + } + } + catch (const std::exception& e) + { + (void)e; + } + catch (...) + { + } + } + return S_OK; + } +}; + +//---------------------------------------------------------------------------- +// +// VideoRecordingSession::ShowSaveDialogWithTrim +// +// Main entry point for trim+save workflow +// +//---------------------------------------------------------------------------- +std::wstring VideoRecordingSession::ShowSaveDialogWithTrim( + HWND hParent, + const std::wstring& suggestedFileName, + const std::wstring& originalVideoPath, + std::wstring& trimmedVideoPath) +{ + trimmedVideoPath.clear(); + + const bool isGif = IsGifPath(originalVideoPath); + + std::wstring videoPathToSave = originalVideoPath; + winrt::TimeSpan trimStart{ 0 }; + winrt::TimeSpan trimEnd{ 0 }; + bool shouldTrim = false; + + // Create save dialog with custom Trim button + auto saveDialog = wil::CoCreateInstance<::IFileSaveDialog>(CLSID_FileSaveDialog); + + FILEOPENDIALOGOPTIONS options; + if (SUCCEEDED(saveDialog->GetOptions(&options))) + saveDialog->SetOptions(options | FOS_FORCEFILESYSTEM); + + wil::com_ptr<::IShellItem> videosItem; + if (SUCCEEDED(SHGetKnownFolderItem(FOLDERID_Videos, KF_FLAG_DEFAULT, nullptr, + IID_IShellItem, (void**)videosItem.put()))) + saveDialog->SetDefaultFolder(videosItem.get()); + + if (isGif) + { + saveDialog->SetDefaultExtension(L".gif"); + COMDLG_FILTERSPEC fileTypes[] = { + { L"GIF Animation", L"*.gif" } + }; + saveDialog->SetFileTypes(_countof(fileTypes), fileTypes); + } + else + { + saveDialog->SetDefaultExtension(L".mp4"); + COMDLG_FILTERSPEC fileTypes[] = { + { L"MP4 Video", L"*.mp4" } + }; + saveDialog->SetFileTypes(_countof(fileTypes), fileTypes); + } + saveDialog->SetFileName(suggestedFileName.c_str()); + saveDialog->SetTitle(L"ZoomIt: Save Video As..."); + + // Add custom Trim button + wil::com_ptr pfdCustomize; + if (SUCCEEDED(saveDialog->QueryInterface(IID_PPV_ARGS(&pfdCustomize)))) + { + pfdCustomize->AddPushButton(2000, L"Trim..."); + } + + // Set up event handler + CTrimFileDialogEvents* pEvents = new CTrimFileDialogEvents(hParent, originalVideoPath, + &trimmedVideoPath, &trimStart, &trimEnd, &shouldTrim); + DWORD dwCookie; + saveDialog->Advise(pEvents, &dwCookie); + + HRESULT hr = saveDialog->Show(hParent); + + saveDialog->Unadvise(dwCookie); + pEvents->Release(); + + if (FAILED(hr)) + { + return std::wstring(); // User cancelled save dialog + } + + // If user clicked Trim button and confirmed, perform the trim + if (shouldTrim) + { + try + { + auto trimOp = isGif ? TrimGifAsync(originalVideoPath, trimStart, trimEnd) + : TrimVideoAsync(originalVideoPath, trimStart, trimEnd); + + // Pump messages while waiting for async operation + while (trimOp.Status() == winrt::AsyncStatus::Started) + { + MSG msg; + while (PeekMessage(&msg, nullptr, 0, 0, PM_REMOVE)) + { + TranslateMessage(&msg); + DispatchMessage(&msg); + } + Sleep(10); + } + + auto trimmedPath = std::wstring(trimOp.GetResults()); + + if (trimmedPath.empty()) + { + MessageBox(hParent, L"Failed to trim video", L"Error", MB_OK | MB_ICONERROR); + return std::wstring(); + } + + trimmedVideoPath = trimmedPath; + videoPathToSave = trimmedPath; + } + catch (...) + { + MessageBox(hParent, L"Failed to trim video", L"Error", MB_OK | MB_ICONERROR); + return std::wstring(); + } + } + + wil::com_ptr<::IShellItem> item; + THROW_IF_FAILED(saveDialog->GetResult(item.put())); + + wil::unique_cotaskmem_string filePath; + THROW_IF_FAILED(item->GetDisplayName(SIGDN_FILESYSPATH, filePath.put())); + + return std::wstring(filePath.get()); +} + +//---------------------------------------------------------------------------- +// +// VideoRecordingSession::ShowTrimDialog +// +// Shows the trim UI dialog +// +//---------------------------------------------------------------------------- +INT_PTR VideoRecordingSession::ShowTrimDialog( + HWND hParent, + const std::wstring& videoPath, + winrt::TimeSpan& trimStart, + winrt::TimeSpan& trimEnd) +{ + std::promise resultPromise; + auto resultFuture = resultPromise.get_future(); + + std::thread staThread([hParent, videoPath, &trimStart, &trimEnd, promise = std::move(resultPromise)]() mutable + { + bool coInitialized = false; + try + { + winrt::init_apartment(winrt::apartment_type::single_threaded); + } + catch (const winrt::hresult_error&) + { + HRESULT hr = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + if (SUCCEEDED(hr)) + { + coInitialized = true; + } + } + + try + { + INT_PTR dlgResult = ShowTrimDialogInternal(hParent, videoPath, trimStart, trimEnd); + promise.set_value(dlgResult); + } + catch (const winrt::hresult_error& e) + { + (void)e; + promise.set_exception(std::current_exception()); + } + catch (const std::exception& e) + { + (void)e; + promise.set_exception(std::current_exception()); + } + catch (...) + { + promise.set_exception(std::current_exception()); + } + + if (coInitialized) + { + CoUninitialize(); + } + }); + + bool quitReceived = false; + while (!quitReceived && resultFuture.wait_for(std::chrono::milliseconds(20)) != std::future_status::ready) + { + MSG msg; + while (PeekMessage(&msg, nullptr, 0, 0, PM_REMOVE)) + { + if (msg.message == WM_QUIT) + { + // WM_QUIT must be reposted so the main application loop can exit cleanly. + quitReceived = true; + } + TranslateMessage(&msg); + DispatchMessage(&msg); + } + } + + // Repost WM_QUIT after waiting for the dialog thread to finish, so the main loop can handle it. + if (quitReceived && hDlgTrimDialog != nullptr) + { + EndDialog(hDlgTrimDialog, IDCANCEL); + PostQuitMessage(0); + } + + INT_PTR dialogResult = quitReceived ? IDCANCEL : resultFuture.get(); + if (staThread.joinable()) + { + staThread.join(); + } + return dialogResult; +} + +INT_PTR VideoRecordingSession::ShowTrimDialogInternal( + HWND hParent, + const std::wstring& videoPath, + winrt::TimeSpan& trimStart, + winrt::TimeSpan& trimEnd) +{ + TrimDialogData data; + data.videoPath = videoPath; + // Initialize from the caller so reopening the trim dialog can preserve prior work. + data.trimStart = trimStart; + data.trimEnd = trimEnd; + data.isGif = IsGifPath(videoPath); + + if (data.isGif) + { + if (!LoadGifFrames(videoPath, &data)) + { + MessageBox(hParent, L"Unable to load the GIF for trimming. The file may be locked or unreadable.", L"Error", MB_OK | MB_ICONERROR); + return IDCANCEL; + } + } + else + { + // Get video duration - use simple file size estimation to avoid blocking + // The actual trim operation will handle the real duration + WIN32_FILE_ATTRIBUTE_DATA fileInfo; + if (GetFileAttributesEx(videoPath.c_str(), GetFileExInfoStandard, &fileInfo)) + { + ULARGE_INTEGER fileSize; + fileSize.LowPart = fileInfo.nFileSizeLow; + fileSize.HighPart = fileInfo.nFileSizeHigh; + + // Estimate: ~10MB per minute for typical 1080p recording + // Duration in 100-nanosecond units (10,000,000 = 1 second) + int64_t estimatedSeconds = fileSize.QuadPart / (10 * 1024 * 1024 / 60); + if (estimatedSeconds < 1) estimatedSeconds = 10; // minimum 10 seconds + if (estimatedSeconds > 3600) estimatedSeconds = 3600; // max 1 hour + + data.videoDuration = winrt::TimeSpan{ estimatedSeconds * 10000000LL }; + if( data.trimEnd.count() <= 0 ) + { + data.trimEnd = data.videoDuration; + } + } + else + { + // Default to 60 seconds if we can't get file size + data.videoDuration = winrt::TimeSpan{ 600000000LL }; + if( data.trimEnd.count() <= 0 ) + { + data.trimEnd = data.videoDuration; + } + } + } + + // Clamp incoming selection to valid bounds now that duration is known. + if( data.videoDuration.count() > 0 ) + { + const int64_t durationTicks = data.videoDuration.count(); + const int64_t endTicks = (data.trimEnd.count() > 0) ? data.trimEnd.count() : durationTicks; + const int64_t clampedEnd = std::clamp( endTicks, 0, durationTicks ); + const int64_t clampedStart = std::clamp( data.trimStart.count(), 0, clampedEnd ); + data.trimStart = winrt::TimeSpan{ clampedStart }; + data.trimEnd = winrt::TimeSpan{ clampedEnd }; + } + + // Track initial selection so we can enable OK only when trimming changes. + data.originalTrimStart = data.trimStart; + data.originalTrimEnd = data.trimEnd; + data.currentPosition = data.trimStart; + data.playbackStartPosition = data.currentPosition; + data.playbackStartPositionValid = true; + + // Center dialog on the screen containing the parent window + HMONITOR hMonitor = MonitorFromWindow(hParent, MONITOR_DEFAULTTONEAREST); + MONITORINFO mi = { sizeof(mi) }; + GetMonitorInfo(hMonitor, &mi); + + // Calculate center position + const int dialogWidth = 521; + const int dialogHeight = 381; + int x = mi.rcWork.left + (mi.rcWork.right - mi.rcWork.left - dialogWidth) / 2; + int y = mi.rcWork.top + (mi.rcWork.bottom - mi.rcWork.top - dialogHeight) / 2; + + // Store position for use in dialog proc + data.dialogX = x; + data.dialogY = y; + + // Pre-load the first frame preview before showing the dialog to avoid "Preview not available" flash + // Must run on a background thread because WinRT async .get() cannot be called on STA (UI) thread + if (!data.isGif) + { + std::thread preloadThread([&data, &videoPath]() + { + winrt::init_apartment(winrt::apartment_type::multi_threaded); + try + { + auto file = winrt::StorageFile::GetFileFromPathAsync(videoPath).get(); + auto clip = winrt::MediaClip::CreateFromFileAsync(file).get(); + + data.composition = winrt::MediaComposition(); + data.composition.Clips().Append(clip); + + // Update to actual duration from clip + auto actualDuration = clip.OriginalDuration(); + if (actualDuration.count() > 0) + { + // If trimEnd was at full length (whether estimated or passed in), snap it to the actual end. + // This handles cases where the file-size estimate was longer or shorter than actual. + const int64_t oldDurationTicks = data.videoDuration.count(); + const int64_t oldTrimEndTicks = data.trimEnd.count(); + const bool endWasFullLength = (oldTrimEndTicks <= 0) || + (oldDurationTicks > 0 && oldTrimEndTicks >= oldDurationTicks); + + data.videoDuration = actualDuration; + + const int64_t newTrimEndTicks = endWasFullLength ? actualDuration.count() + : (std::min)(oldTrimEndTicks, actualDuration.count()); + data.trimEnd = winrt::TimeSpan{ newTrimEndTicks }; + + const int64_t oldOrigEndTicks = data.originalTrimEnd.count(); + const bool origEndWasFullLength = (oldOrigEndTicks <= 0) || + (oldDurationTicks > 0 && oldOrigEndTicks >= oldDurationTicks); + const int64_t newOrigEndTicks = origEndWasFullLength ? actualDuration.count() + : (std::min)(oldOrigEndTicks, actualDuration.count()); + data.originalTrimEnd = winrt::TimeSpan{ newOrigEndTicks }; + } + + // Get first frame thumbnail + const int64_t requestTicks = std::clamp(data.currentPosition.count(), 0, data.videoDuration.count()); + auto stream = data.composition.GetThumbnailAsync( + winrt::TimeSpan{ requestTicks }, + 0, 0, + winrt::VideoFramePrecision::NearestFrame).get(); + + if (stream) + { + winrt::com_ptr wicFactory; + if (SUCCEEDED(CoCreateInstance(CLSID_WICImagingFactory, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(wicFactory.put())))) + { + winrt::com_ptr istream; + auto streamAsUnknown = static_cast<::IUnknown*>(winrt::get_abi(stream)); + if (SUCCEEDED(CreateStreamOverRandomAccessStream(streamAsUnknown, IID_PPV_ARGS(istream.put()))) && istream) + { + winrt::com_ptr decoder; + if (SUCCEEDED(wicFactory->CreateDecoderFromStream(istream.get(), nullptr, WICDecodeMetadataCacheOnDemand, decoder.put()))) + { + winrt::com_ptr frame; + if (SUCCEEDED(decoder->GetFrame(0, frame.put()))) + { + winrt::com_ptr converter; + if (SUCCEEDED(wicFactory->CreateFormatConverter(converter.put()))) + { + if (SUCCEEDED(converter->Initialize(frame.get(), GUID_WICPixelFormat32bppBGRA, + WICBitmapDitherTypeNone, nullptr, 0.0, + WICBitmapPaletteTypeCustom))) + { + UINT width, height; + converter->GetSize(&width, &height); + + BITMAPINFO bmi = {}; + bmi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); + bmi.bmiHeader.biWidth = width; + bmi.bmiHeader.biHeight = -static_cast(height); + bmi.bmiHeader.biPlanes = 1; + bmi.bmiHeader.biBitCount = 32; + bmi.bmiHeader.biCompression = BI_RGB; + + void* bits = nullptr; + HDC hdcScreen = GetDC(nullptr); + HBITMAP hBitmap = CreateDIBSection(hdcScreen, &bmi, DIB_RGB_COLORS, &bits, nullptr, 0); + ReleaseDC(nullptr, hdcScreen); + + if (hBitmap && bits) + { + converter->CopyPixels(nullptr, width * 4, width * height * 4, static_cast(bits)); + data.hPreviewBitmap = hBitmap; + data.previewBitmapOwned = true; + data.lastRenderedPreview.store(requestTicks, std::memory_order_relaxed); + } + } + } + } + } + } + } + } + } + catch (...) + { + // If preloading fails, the dialog will show "Preview not available" briefly + // but will recover via the async UpdateVideoPreview path + } + }); + preloadThread.join(); + } + + auto result = DialogBoxParam( + GetModuleHandle(nullptr), + MAKEINTRESOURCE(IDD_VIDEO_TRIM), + hParent, + TrimDialogProc, + reinterpret_cast(&data)); + + if (result == IDOK) + { + trimStart = data.trimStart; + trimEnd = data.trimEnd; + } + + return result; +} + +static void UpdatePositionUI(HWND hDlg, VideoRecordingSession::TrimDialogData* pData, bool invalidateTimeline = true) +{ + if (!pData || !hDlg) + { + return; + } + + const auto previewTime = pData->previewOverrideActive ? pData->previewOverride : pData->currentPosition; + // Show time relative to left grip (trimStart) + const auto relativeTime = winrt::TimeSpan{ (std::max)(previewTime.count() - pData->trimStart.count(), int64_t{ 0 }) }; + SetTimeText(hDlg, IDC_TRIM_POSITION_LABEL, relativeTime, true); + if (invalidateTimeline) + { + InvalidateRect(GetDlgItem(hDlg, IDC_TRIM_TIMELINE), nullptr, FALSE); + } +} + +static void SyncMediaPlayerPosition(VideoRecordingSession::TrimDialogData* pData) +{ + if (!pData || !pData->mediaPlayer) + { + return; + } + + try + { + auto session = pData->mediaPlayer.PlaybackSession(); + if (session) + { + // The selection (trimStart..trimEnd) determines what will be trimmed, + // but playback may start before trimStart. Clamp only to valid media bounds. + const int64_t upper = (pData->trimEnd.count() > 0) ? pData->trimEnd.count() : pData->videoDuration.count(); + const int64_t clampedTicks = std::clamp(pData->currentPosition.count(), 0, upper); + session.Position(winrt::TimeSpan{ clampedTicks }); + } + } + catch (...) + { + } +} + +static void CleanupMediaPlayer(VideoRecordingSession::TrimDialogData* pData) +{ + if (!pData || !pData->mediaPlayer) + { + return; + } + + try + { + auto session = pData->mediaPlayer.PlaybackSession(); + if (session) + { + if (pData->positionChangedToken.value) + { + session.PositionChanged(pData->positionChangedToken); + pData->positionChangedToken = {}; + } + if (pData->stateChangedToken.value) + { + session.PlaybackStateChanged(pData->stateChangedToken); + pData->stateChangedToken = {}; + } + } + + if (pData->frameAvailableToken.value) + { + pData->mediaPlayer.VideoFrameAvailable(pData->frameAvailableToken); + pData->frameAvailableToken = {}; + } + + pData->mediaPlayer.Close(); + } + catch (...) + { + } + + pData->mediaPlayer = nullptr; + pData->frameCopyInProgress.store(false, std::memory_order_relaxed); +} + +//---------------------------------------------------------------------------- +// +// Helper: Update video frame preview +// +//---------------------------------------------------------------------------- +static void UpdateVideoPreview(HWND hDlg, VideoRecordingSession::TrimDialogData* pData, bool invalidateTimeline = true) +{ + if (!pData) + { + return; + } + + const auto previewTime = pData->previewOverrideActive ? pData->previewOverride : pData->currentPosition; + + // Update position label and timeline + UpdatePositionUI(hDlg, pData, invalidateTimeline); + + // When playing with the frame server, frames arrive via VideoFrameAvailable; avoid extra thumbnails. + if (pData->isPlaying.load(std::memory_order_relaxed) && pData->mediaPlayer) + { + return; + } + + const int64_t requestTicks = previewTime.count(); + pData->latestPreviewRequest.store(requestTicks, std::memory_order_relaxed); + + if (pData->loadingPreview.exchange(true)) + { + // A preview request is already running; we'll schedule the latest once it completes. + return; + } + + if (pData->isGif) + { + // Use request time directly (don't clamp to trim bounds) so thumbnail updates outside trim region + const int64_t clampedTicks = std::clamp(requestTicks, 0, pData->videoDuration.count()); + if (!pData->gifFrames.empty()) + { + const size_t frameIndex = FindGifFrameIndex(pData->gifFrames, clampedTicks); + pData->gifLastFrameIndex = frameIndex; + { + std::lock_guard lock(pData->previewBitmapMutex); + if (pData->hPreviewBitmap && pData->previewBitmapOwned) + { + DeleteObject(pData->hPreviewBitmap); + } + pData->hPreviewBitmap = pData->gifFrames[frameIndex].hBitmap; + pData->previewBitmapOwned = false; + } + + pData->lastRenderedPreview.store(clampedTicks, std::memory_order_relaxed); + pData->loadingPreview.store(false, std::memory_order_relaxed); + + if (hDlg) + { + InvalidateRect(GetDlgItem(hDlg, IDC_TRIM_PREVIEW), nullptr, FALSE); + } + return; + } + + pData->loadingPreview.store(false, std::memory_order_relaxed); + return; + } + + std::thread([](HWND hDlg, VideoRecordingSession::TrimDialogData* pData, int64_t requestTicks) + { + winrt::init_apartment(winrt::apartment_type::multi_threaded); + + const int64_t requestTicksRaw = requestTicks; + bool updatedBitmap = false; + + bool durationChanged = false; + + try + { + if (!pData->composition) + { + auto file = winrt::StorageFile::GetFileFromPathAsync(pData->videoPath).get(); + auto clip = winrt::MediaClip::CreateFromFileAsync(file).get(); + + pData->composition = winrt::MediaComposition(); + pData->composition.Clips().Append(clip); + + auto actualDuration = clip.OriginalDuration(); + if (actualDuration.count() > 0) + { + const int64_t oldDurationTicks = pData->videoDuration.count(); + if (oldDurationTicks != actualDuration.count()) + { + durationChanged = true; + } + + // Update duration, but preserve a user-chosen trim end. + // If the trim end was "full length" (old duration or 0), keep it full length. + pData->videoDuration = actualDuration; + + const int64_t oldTrimEndTicks = pData->trimEnd.count(); + const bool endWasFullLength = (oldTrimEndTicks <= 0) || (oldDurationTicks > 0 && oldTrimEndTicks >= oldDurationTicks); + const int64_t newTrimEndTicks = endWasFullLength ? actualDuration.count() + : (std::min)(oldTrimEndTicks, actualDuration.count()); + pData->trimEnd = winrt::TimeSpan{ newTrimEndTicks }; + + const int64_t oldOrigEndTicks = pData->originalTrimEnd.count(); + const bool origEndWasFullLength = (oldOrigEndTicks <= 0) || (oldDurationTicks > 0 && oldOrigEndTicks >= oldDurationTicks); + const int64_t newOrigEndTicks = origEndWasFullLength ? actualDuration.count() + : (std::min)(oldOrigEndTicks, actualDuration.count()); + pData->originalTrimEnd = winrt::TimeSpan{ newOrigEndTicks }; + + // Clamp starts to the new end. + if (pData->originalTrimStart.count() > pData->originalTrimEnd.count()) + { + pData->originalTrimStart = pData->originalTrimEnd; + } + if (pData->trimStart.count() > pData->trimEnd.count()) + { + pData->trimStart = pData->trimEnd; + } + } + } + + auto composition = pData->composition; + if (composition) + { + auto durationTicks = composition.Duration().count(); + if (durationTicks > 0) + { + requestTicks = std::clamp(requestTicks, 0, durationTicks); + } + + const bool isPlaying = pData->isPlaying.load(std::memory_order_relaxed); + const UINT32 reqW = isPlaying ? kPreviewRequestWidthPlaying : 0; + const UINT32 reqH = isPlaying ? kPreviewRequestHeightPlaying : 0; + + auto stream = composition.GetThumbnailAsync( + winrt::TimeSpan{ requestTicks }, + reqW, + reqH, + winrt::VideoFramePrecision::NearestFrame).get(); + + if (stream) + { + winrt::com_ptr wicFactory; + if (SUCCEEDED(CoCreateInstance(CLSID_WICImagingFactory, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(wicFactory.put())))) + { + winrt::com_ptr istream; + auto streamAsUnknown = static_cast<::IUnknown*>(winrt::get_abi(stream)); + if (SUCCEEDED(CreateStreamOverRandomAccessStream(streamAsUnknown, IID_PPV_ARGS(istream.put()))) && istream) + { + winrt::com_ptr decoder; + if (SUCCEEDED(wicFactory->CreateDecoderFromStream(istream.get(), nullptr, WICDecodeMetadataCacheOnDemand, decoder.put()))) + { + winrt::com_ptr frame; + if (SUCCEEDED(decoder->GetFrame(0, frame.put()))) + { + winrt::com_ptr converter; + if (SUCCEEDED(wicFactory->CreateFormatConverter(converter.put()))) + { + if (SUCCEEDED(converter->Initialize(frame.get(), GUID_WICPixelFormat32bppBGRA, + WICBitmapDitherTypeNone, nullptr, 0.0, + WICBitmapPaletteTypeCustom))) + { + UINT width, height; + converter->GetSize(&width, &height); + + BITMAPINFO bmi = {}; + bmi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); + bmi.bmiHeader.biWidth = width; + bmi.bmiHeader.biHeight = -static_cast(height); + bmi.bmiHeader.biPlanes = 1; + bmi.bmiHeader.biBitCount = 32; + bmi.bmiHeader.biCompression = BI_RGB; + + void* bits = nullptr; + HDC hdcScreen = GetDC(nullptr); + HBITMAP hBitmap = CreateDIBSection(hdcScreen, &bmi, DIB_RGB_COLORS, &bits, nullptr, 0); + ReleaseDC(nullptr, hdcScreen); + + if (hBitmap && bits) + { + converter->CopyPixels(nullptr, width * 4, width * height * 4, static_cast(bits)); + + { + std::lock_guard lock(pData->previewBitmapMutex); + if (pData->hPreviewBitmap && pData->previewBitmapOwned) + { + DeleteObject(pData->hPreviewBitmap); + } + pData->hPreviewBitmap = hBitmap; + pData->previewBitmapOwned = true; + } + updatedBitmap = true; + } + } + } + } + } + } + } + } + } + } + catch (...) + { + } + + pData->loadingPreview.store(false, std::memory_order_relaxed); + + if (updatedBitmap) + { + pData->lastRenderedPreview.store(requestTicks, std::memory_order_relaxed); + PostMessage(hDlg, WMU_PREVIEW_READY, 0, 0); + } + + if (pData->latestPreviewRequest.load(std::memory_order_relaxed) != requestTicksRaw) + { + PostMessage(hDlg, WMU_PREVIEW_SCHEDULED, 0, 0); + } + + if (durationChanged) + { + PostMessage(hDlg, WMU_DURATION_CHANGED, 0, 0); + } + }, hDlg, pData, requestTicks).detach(); +} + +//---------------------------------------------------------------------------- +// +// Helper: Draw custom timeline with handles +// +//---------------------------------------------------------------------------- +static void DrawTimeline(HDC hdc, RECT rc, VideoRecordingSession::TrimDialogData* pData, UINT dpi) +{ + const int width = rc.right - rc.left; + const int height = rc.bottom - rc.top; + + // Scale constants for DPI + const int timelinePadding = ScaleForDpi(kTimelinePadding, dpi); + const int timelineTrackHeight = ScaleForDpi(kTimelineTrackHeight, dpi); + const int timelineTrackTopOffset = ScaleForDpi(kTimelineTrackTopOffset, dpi); + const int timelineHandleHalfWidth = ScaleForDpi(kTimelineHandleHalfWidth, dpi); + const int timelineHandleHeight = ScaleForDpi(kTimelineHandleHeight, dpi); + + // Create memory DC for double buffering + HDC hdcMem = CreateCompatibleDC(hdc); + HBITMAP hbmMem = CreateCompatibleBitmap(hdc, width, height); + HBITMAP hbmOld = static_cast(SelectObject(hdcMem, hbmMem)); + + // Draw to memory DC - use dark mode colors if enabled + const bool darkMode = IsDarkModeEnabled(); + HBRUSH hBackground = CreateSolidBrush(darkMode ? DarkMode::BackgroundColor : GetSysColor(COLOR_BTNFACE)); + RECT rcMem = { 0, 0, width, height }; + FillRect(hdcMem, &rcMem, hBackground); + DeleteObject(hBackground); + + const int trackLeft = timelinePadding; + const int trackRight = width - timelinePadding; + const int trackTop = timelineTrackTopOffset; + const int trackBottom = trackTop + timelineTrackHeight; + + RECT rcTrack = { trackLeft, trackTop, trackRight, trackBottom }; + HBRUSH hTrackBase = CreateSolidBrush(darkMode ? RGB(60, 60, 65) : RGB(214, 219, 224)); + FillRect(hdcMem, &rcTrack, hTrackBase); + DeleteObject(hTrackBase); + + int startX = std::clamp(TimelineTimeToClientX(pData, pData->trimStart, width, dpi), trackLeft, trackRight); + int endX = std::clamp(TimelineTimeToClientX(pData, pData->trimEnd, width, dpi), trackLeft, trackRight); + if (endX < startX) + { + std::swap(startX, endX); + } + + RECT rcBefore{ trackLeft, trackTop, startX, trackBottom }; + RECT rcAfter{ endX, trackTop, trackRight, trackBottom }; + HBRUSH hMuted = CreateSolidBrush(darkMode ? RGB(50, 50, 55) : RGB(198, 202, 206)); + FillRect(hdcMem, &rcBefore, hMuted); + FillRect(hdcMem, &rcAfter, hMuted); + DeleteObject(hMuted); + + RECT rcActive{ startX, trackTop, endX, trackBottom }; + HBRUSH hActive = CreateSolidBrush(RGB(90, 147, 250)); + FillRect(hdcMem, &rcActive, hActive); + DeleteObject(hActive); + + HPEN hOutline = CreatePen(PS_SOLID, 1, darkMode ? RGB(80, 80, 85) : RGB(150, 150, 150)); + HPEN hOldPen = static_cast(SelectObject(hdcMem, hOutline)); + MoveToEx(hdcMem, trackLeft, trackTop, nullptr); + LineTo(hdcMem, trackRight, trackTop); + LineTo(hdcMem, trackRight, trackBottom); + LineTo(hdcMem, trackLeft, trackBottom); + LineTo(hdcMem, trackLeft, trackTop); + SelectObject(hdcMem, hOldPen); + DeleteObject(hOutline); + + const int trackWidth = trackRight - trackLeft; + if (trackWidth > 0 && pData && pData->videoDuration.count() > 0) + { + const int tickTop = trackBottom + ScaleForDpi(2, dpi); + const int tickMajorBottom = tickTop + ScaleForDpi(10, dpi); + const int tickMinorBottom = tickTop + ScaleForDpi(6, dpi); + + const std::array fractions{ 0.0, 0.25, 0.5, 0.75, 1.0 }; + HPEN hTickPen = CreatePen(PS_SOLID, 1, darkMode ? RGB(100, 100, 105) : RGB(150, 150, 150)); + HPEN hOldTickPen = static_cast(SelectObject(hdcMem, hTickPen)); + SetBkMode(hdcMem, TRANSPARENT); + SetTextColor(hdcMem, darkMode ? RGB(140, 140, 140) : RGB(80, 80, 80)); + + // Use consistent font for all timeline text - scale for DPI (12pt) + const int fontSize = -MulDiv(12, static_cast(dpi), USER_DEFAULT_SCREEN_DPI); + HFONT hTimelineFont = CreateFont(fontSize, 0, 0, 0, FW_NORMAL, FALSE, FALSE, FALSE, DEFAULT_CHARSET, + OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, CLEARTYPE_QUALITY, + DEFAULT_PITCH | FF_SWISS, L"Segoe UI"); + HFONT hOldTimelineFont = static_cast(SelectObject(hdcMem, hTimelineFont)); + + for (size_t i = 0; i < fractions.size(); ++i) + { + const double fraction = fractions[i]; + const int x = trackLeft + static_cast(std::round(fraction * trackWidth)); + const bool isMajor = (fraction == 0.0) || (fraction == 0.5) || (fraction == 1.0); + MoveToEx(hdcMem, x, tickTop, nullptr); + LineTo(hdcMem, x, isMajor ? tickMajorBottom : tickMinorBottom); + + if (fraction > 0.0 && fraction < 1.0) + { + // Calculate marker time within the full video duration (untrimmed) + const auto markerTime = winrt::TimeSpan{ static_cast(fraction * pData->videoDuration.count()) }; + // For short videos (under 60 seconds), show fractional seconds to distinguish markers + const bool showMilliseconds = (pData->videoDuration.count() < 600000000LL); // 60 seconds in 100ns ticks + const std::wstring markerText = FormatTrimTime(markerTime, showMilliseconds); + const int markerHalfWidth = ScaleForDpi(showMilliseconds ? 45 : 35, dpi); + const int markerHeight = ScaleForDpi(26, dpi); + RECT rcMarker{ x - markerHalfWidth, tickMajorBottom + ScaleForDpi(10, dpi), x + markerHalfWidth, tickMajorBottom + ScaleForDpi(2, dpi) + markerHeight }; + DrawText(hdcMem, markerText.c_str(), -1, &rcMarker, DT_CENTER | DT_TOP | DT_SINGLELINE | DT_NOPREFIX); + } + } + + SelectObject(hdcMem, hOldTimelineFont); + DeleteObject(hTimelineFont); + SelectObject(hdcMem, hOldTickPen); + DeleteObject(hTickPen); + } + + auto drawGripper = [&](int x) + { + RECT handleRect{ + x - timelineHandleHalfWidth, + trackTop - (timelineHandleHeight - timelineTrackHeight) / 2, + x + timelineHandleHalfWidth, + trackTop - (timelineHandleHeight - timelineTrackHeight) / 2 + timelineHandleHeight + }; + + const COLORREF fillColor = darkMode ? RGB(165, 165, 165) : RGB(200, 200, 200); + const COLORREF lineColor = darkMode ? RGB(90, 90, 90) : RGB(120, 120, 120); + const int cornerRadius = (std::max)(ScaleForDpi(6, dpi), timelineHandleHalfWidth); + const int lineInset = ScaleForDpi(6, dpi); + const int lineWidth = (std::max)(1, ScaleForDpi(2, dpi)); + + HBRUSH hFill = CreateSolidBrush(fillColor); + HPEN hNullPen = static_cast(SelectObject(hdcMem, GetStockObject(NULL_PEN))); + HBRUSH hOldBrush2 = static_cast(SelectObject(hdcMem, hFill)); + RoundRect(hdcMem, handleRect.left, handleRect.top, handleRect.right, handleRect.bottom, cornerRadius, cornerRadius); + SelectObject(hdcMem, hOldBrush2); + SelectObject(hdcMem, hNullPen); + DeleteObject(hFill); + + // Dark vertical line in the middle. + HPEN hLinePen = CreatePen(PS_SOLID, lineWidth, lineColor); + HPEN hOldLinePen = static_cast(SelectObject(hdcMem, hLinePen)); + const int y1 = handleRect.top + lineInset; + const int y2 = handleRect.bottom - lineInset; + MoveToEx(hdcMem, x, y1, nullptr); + LineTo(hdcMem, x, y2); + SelectObject(hdcMem, hOldLinePen); + DeleteObject(hLinePen); + }; + + drawGripper(startX); + drawGripper(endX); + + const int posX = std::clamp(TimelineTimeToClientX(pData, pData->currentPosition, width, dpi), trackLeft, trackRight); + const int posLineWidth = ScaleForDpi(2, dpi); + const int posLineExtend = ScaleForDpi(12, dpi); + const int posLineBelow = ScaleForDpi(22, dpi); + HPEN hPositionPen = CreatePen(PS_SOLID, posLineWidth, RGB(33, 150, 243)); + hOldPen = static_cast(SelectObject(hdcMem, hPositionPen)); + MoveToEx(hdcMem, posX, trackTop - posLineExtend, nullptr); + LineTo(hdcMem, posX, trackBottom + posLineBelow); + SelectObject(hdcMem, hOldPen); + DeleteObject(hPositionPen); + + const int ellipseRadius = ScaleForDpi(6, dpi); + const int ellipseTop = ScaleForDpi(12, dpi); + const int ellipseBottom = ScaleForDpi(24, dpi); + HBRUSH hPositionBrush = CreateSolidBrush(RGB(33, 150, 243)); + HBRUSH hOldBrush = static_cast(SelectObject(hdcMem, hPositionBrush)); + HPEN hOldPenForEllipse = static_cast(SelectObject(hdcMem, GetStockObject(NULL_PEN))); + Ellipse(hdcMem, posX - ellipseRadius, trackBottom + ellipseTop, posX + ellipseRadius, trackBottom + ellipseBottom); + SelectObject(hdcMem, hOldPenForEllipse); + SelectObject(hdcMem, hOldBrush); + DeleteObject(hPositionBrush); + + // Set font for start/end labels (same font used for tick labels - 12pt) + SetBkMode(hdcMem, TRANSPARENT); + SetTextColor(hdcMem, darkMode ? RGB(140, 140, 140) : RGB(80, 80, 80)); + int labelFontSize = -MulDiv(12, static_cast(dpi), USER_DEFAULT_SCREEN_DPI); + HFONT hFont = CreateFont(labelFontSize, 0, 0, 0, FW_NORMAL, FALSE, FALSE, FALSE, DEFAULT_CHARSET, + OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, CLEARTYPE_QUALITY, + DEFAULT_PITCH | FF_SWISS, L"Segoe UI"); + HFONT hOldFont = static_cast(SelectObject(hdcMem, hFont)); + + // Align with intermediate marker labels: use same calculation as rcMarker + // tickTop = trackBottom + 10, tickMajorBottom = tickTop + 10, marker starts at tickMajorBottom + 2 + const int tickTopForLabels = trackBottom + ScaleForDpi(10, dpi); + const int tickMajorBottomForLabels = tickTopForLabels + ScaleForDpi(10, dpi); + int labelTop = tickMajorBottomForLabels + ScaleForDpi(2, dpi); + int labelBottom = labelTop + ScaleForDpi(26, dpi); + // For short videos (under 60 seconds), show fractional seconds + const bool showMilliseconds = (pData->videoDuration.count() < 600000000LL); // 60 seconds in 100ns ticks + int labelWidth = ScaleForDpi(showMilliseconds ? 80 : 70, dpi); + // Start label: draw to the right of trackLeft (left-aligned) + RECT rcStartLabel{ trackLeft, labelTop, trackLeft + labelWidth, labelBottom }; + const std::wstring startLabel = FormatTrimTime(pData->trimStart, showMilliseconds); + DrawText(hdcMem, startLabel.c_str(), -1, &rcStartLabel, DT_LEFT | DT_TOP | DT_SINGLELINE); + + // End label: draw to the left of trackRight (right-aligned) + RECT rcEndLabel{ trackRight - labelWidth, labelTop, trackRight, labelBottom }; + const std::wstring endLabel = FormatTrimTime(pData->trimEnd, showMilliseconds); + DrawText(hdcMem, endLabel.c_str(), -1, &rcEndLabel, DT_RIGHT | DT_TOP | DT_SINGLELINE); + + SelectObject(hdcMem, hOldFont); + DeleteObject(hFont); + + // Copy the buffered image to the screen + BitBlt(hdc, rc.left, rc.top, width, height, hdcMem, 0, 0, SRCCOPY); + + // Clean up + SelectObject(hdcMem, hbmOld); + DeleteObject(hbmMem); + DeleteDC(hdcMem); +} + +//---------------------------------------------------------------------------- +// +// Helper: Mouse interaction for the trim timeline +// +//---------------------------------------------------------------------------- +namespace +{ + constexpr UINT_PTR kPlaybackTimerId = 1; + constexpr UINT kPlaybackTimerIntervalMs = 16; // Fallback for GIF; MP4 uses multimedia timer + constexpr int64_t kPlaybackStepTicks = static_cast(kPlaybackTimerIntervalMs) * 10'000; + constexpr UINT WMU_MM_TIMER_TICK = WM_USER + 10; // Posted by multimedia timer callback + constexpr UINT kMMTimerIntervalMs = 8; // 8ms for ~120Hz update rate +} + +// Multimedia timer callback - runs in a separate thread, just posts a message +static void CALLBACK MMTimerCallback(UINT /*uTimerID*/, UINT /*uMsg*/, DWORD_PTR dwUser, DWORD_PTR /*dw1*/, DWORD_PTR /*dw2*/) +{ + HWND hDlg = reinterpret_cast(dwUser); + if (hDlg && IsWindow(hDlg)) + { + PostMessage(hDlg, WMU_MM_TIMER_TICK, 0, 0); + } +} + +static void StopMMTimer(VideoRecordingSession::TrimDialogData* pData) +{ + if (pData && pData->mmTimerId != 0) + { + timeKillEvent(pData->mmTimerId); + pData->mmTimerId = 0; + } +} + +static bool StartMMTimer(HWND hDlg, VideoRecordingSession::TrimDialogData* pData) +{ + if (!pData || !hDlg) + { + return false; + } + + StopMMTimer(pData); + + pData->mmTimerId = timeSetEvent( + kMMTimerIntervalMs, + 1, // 1ms resolution + MMTimerCallback, + reinterpret_cast(hDlg), + TIME_PERIODIC | TIME_KILL_SYNCHRONOUS); + + return pData->mmTimerId != 0; +} + +static void RefreshPlaybackButtons(HWND hDlg) +{ + if (!hDlg) + { + return; + } + + InvalidateRect(GetDlgItem(hDlg, IDC_TRIM_SKIP_START), nullptr, FALSE); + InvalidateRect(GetDlgItem(hDlg, IDC_TRIM_REWIND), nullptr, FALSE); + InvalidateRect(GetDlgItem(hDlg, IDC_TRIM_PLAY_PAUSE), nullptr, FALSE); + InvalidateRect(GetDlgItem(hDlg, IDC_TRIM_FORWARD), nullptr, FALSE); + InvalidateRect(GetDlgItem(hDlg, IDC_TRIM_SKIP_END), nullptr, FALSE); +} + +static void HandlePlaybackCommand(int controlId, VideoRecordingSession::TrimDialogData* pData) +{ + if (!pData || !pData->hDialog) + { + return; + } + + HWND hDlg = pData->hDialog; + + // Helper lambda to invalidate cached start frame when position changes + auto invalidateCachedFrame = [pData]() + { + std::lock_guard lock(pData->previewBitmapMutex); + if (pData->hCachedStartFrame) + { + DeleteObject(pData->hCachedStartFrame); + pData->hCachedStartFrame = nullptr; + } + }; + + switch (controlId) + { + case IDC_TRIM_PLAY_PAUSE: + if (pData->isPlaying.load(std::memory_order_relaxed)) + { + StopPlayback(hDlg, pData, true); + } + else + { + // Always start playback from current time selector position + pData->playbackStartPosition = pData->currentPosition; + pData->playbackStartPositionValid = true; + invalidateCachedFrame(); + StartPlaybackAsync(hDlg, pData); + } + break; + + case IDC_TRIM_REWIND: + { + StopPlayback(hDlg, pData, false); + // Use 1 second step for timelines < 20 seconds, 2 seconds + const int64_t duration = pData->trimEnd.count() - pData->trimStart.count(); + const int64_t stepTicks = (duration < 200'000'000) ? 10'000'000 : kJogStepTicks; + const int64_t newTicks = (std::max)(pData->trimStart.count(), pData->currentPosition.count() - stepTicks); + pData->currentPosition = winrt::TimeSpan{ newTicks }; + pData->playbackStartPosition = pData->currentPosition; + pData->playbackStartPositionValid = true; + invalidateCachedFrame(); + SyncMediaPlayerPosition(pData); + UpdateVideoPreview(hDlg, pData); + break; + } + + case IDC_TRIM_FORWARD: + { + StopPlayback(hDlg, pData, false); + // Use 1 second step for timelines < 20 seconds, 2 seconds + const int64_t duration = pData->trimEnd.count() - pData->trimStart.count(); + const int64_t stepTicks = (duration < 200'000'000) ? 10'000'000 : kJogStepTicks; + const int64_t newTicks = (std::min)(pData->trimEnd.count(), pData->currentPosition.count() + stepTicks); + pData->currentPosition = winrt::TimeSpan{ newTicks }; + pData->playbackStartPosition = pData->currentPosition; + pData->playbackStartPositionValid = true; + invalidateCachedFrame(); + SyncMediaPlayerPosition(pData); + UpdateVideoPreview(hDlg, pData); + break; + } + + case IDC_TRIM_SKIP_END: + { + StopPlayback(hDlg, pData, false); + pData->currentPosition = pData->trimEnd; + pData->playbackStartPosition = pData->currentPosition; + pData->playbackStartPositionValid = true; + invalidateCachedFrame(); + SyncMediaPlayerPosition(pData); + UpdateVideoPreview(hDlg, pData); + break; + } + + default: + StopPlayback(hDlg, pData, false); + pData->currentPosition = pData->trimStart; + pData->playbackStartPosition = pData->currentPosition; + pData->playbackStartPositionValid = true; + invalidateCachedFrame(); + SyncMediaPlayerPosition(pData); + UpdateVideoPreview(hDlg, pData); + break; + } + + RefreshPlaybackButtons(hDlg); +} + +static void StopPlayback(HWND hDlg, VideoRecordingSession::TrimDialogData* pData, bool capturePosition) +{ + if (!pData) + { + return; + } + + // Invalidate any in-flight StartPlaybackAsync continuation (e.g., after awaiting file load). + pData->playbackCommandSerial.fetch_add(1, std::memory_order_acq_rel); + + const bool wasPlaying = pData->isPlaying.exchange(false, std::memory_order_acq_rel); + ResetSmoothPlayback(pData); + + // Cancel any pending initial seek suppression. + pData->pendingInitialSeek.store(false, std::memory_order_relaxed); + pData->pendingInitialSeekTicks.store(0, std::memory_order_relaxed); + + // Stop audio playback and align media position with UI state, but keep player alive for resume + if (pData->mediaPlayer) + { + try + { + auto session = pData->mediaPlayer.PlaybackSession(); + if (session) + { + if (capturePosition) + { + pData->currentPosition = session.Position(); + } + session.Position(pData->currentPosition); + } + pData->mediaPlayer.Pause(); + } + catch (...) + { + } + } + + if (hDlg) + { + if (wasPlaying) + { + StopMMTimer(pData); // Stop multimedia timer for MP4 + KillTimer(hDlg, kPlaybackTimerId); // Stop regular timer for GIF + } + RefreshPlaybackButtons(hDlg); + } +} + +static winrt::fire_and_forget StartPlaybackAsync(HWND hDlg, VideoRecordingSession::TrimDialogData* pData) +{ + if (!pData || !hDlg) + { + co_return; + } + + if (pData->trimEnd.count() <= pData->trimStart.count()) + { + co_return; + } + + ResetSmoothPlayback(pData); + + // If playhead is at/past selection end, restart from trimStart. + if (pData->currentPosition.count() >= pData->trimEnd.count()) + { + pData->currentPosition = pData->trimStart; + UpdateVideoPreview(hDlg, pData); + } + + // Capture resume position (where playback should start/resume from). + const auto resumePosition = pData->currentPosition; + + // Suppress the brief Position==0 report before the initial seek takes effect. + pData->pendingInitialSeek.store(resumePosition.count() > 0, std::memory_order_relaxed); + pData->pendingInitialSeekTicks.store(resumePosition.count(), std::memory_order_relaxed); + + // Capture loop anchor only if not already set by an explicit user positioning. + // This keeps the loop point stable across pause/resume. + if (!pData->playbackStartPositionValid) + { + pData->playbackStartPosition = resumePosition; + pData->playbackStartPositionValid = true; + } + + // Cache the current preview frame for instant restore when playback stops. + // Only cache if we have a valid preview and it matches the playback start position. + { + std::lock_guard lock(pData->previewBitmapMutex); + // Clear any previous cached frame + if (pData->hCachedStartFrame) + { + DeleteObject(pData->hCachedStartFrame); + pData->hCachedStartFrame = nullptr; + } + // Cache if we have a valid preview at the current position + if (pData->hPreviewBitmap && pData->lastRenderedPreview.load(std::memory_order_relaxed) >= 0) + { + // Duplicate the bitmap so we have our own copy + BITMAP bm{}; + if (GetObject(pData->hPreviewBitmap, sizeof(bm), &bm)) + { + HDC hdcScreen = GetDC(nullptr); + HDC hdcSrc = CreateCompatibleDC(hdcScreen); + HDC hdcDst = CreateCompatibleDC(hdcScreen); + HBITMAP hCopy = CreateCompatibleBitmap(hdcScreen, bm.bmWidth, bm.bmHeight); + if (hCopy) + { + HBITMAP hOldSrc = static_cast(SelectObject(hdcSrc, pData->hPreviewBitmap)); + HBITMAP hOldDst = static_cast(SelectObject(hdcDst, hCopy)); + BitBlt(hdcDst, 0, 0, bm.bmWidth, bm.bmHeight, hdcSrc, 0, 0, SRCCOPY); + SelectObject(hdcSrc, hOldSrc); + SelectObject(hdcDst, hOldDst); + pData->hCachedStartFrame = hCopy; + pData->cachedStartFramePosition = pData->playbackStartPosition; + } + DeleteDC(hdcSrc); + DeleteDC(hdcDst); + ReleaseDC(nullptr, hdcScreen); + } + } + } + +#if _DEBUG + OutputDebugStringW((L"[Trim] StartPlayback: currentPos=" + std::to_wstring(pData->currentPosition.count()) + + L" playbackStartPos=" + std::to_wstring(pData->playbackStartPosition.count()) + + L" trimStart=" + std::to_wstring(pData->trimStart.count()) + + L" trimEnd=" + std::to_wstring(pData->trimEnd.count()) + L"\n").c_str()); +#endif + + bool expected = false; + if (!pData->isPlaying.compare_exchange_strong(expected, true, std::memory_order_relaxed)) + { + co_return; + } + + const uint64_t startSerial = pData->playbackCommandSerial.fetch_add(1, std::memory_order_acq_rel) + 1; + + if (pData->isGif) + { + // Initialize GIF timing so playback begins at the current playhead position + // (not at the start of the containing frame). + auto now = std::chrono::steady_clock::now(); + if (!pData->gifFrames.empty() && pData->videoDuration.count() > 0) + { + const int64_t clampedTicks = std::clamp(resumePosition.count(), 0, pData->videoDuration.count()); + const size_t frameIndex = FindGifFrameIndex(pData->gifFrames, clampedTicks); + const auto& frame = pData->gifFrames[frameIndex]; + const int64_t offsetTicks = std::clamp(clampedTicks - frame.start.count(), 0, frame.duration.count()); + const auto offsetMs = std::chrono::milliseconds(offsetTicks / 10'000); + pData->gifFrameStartTime = now - offsetMs; + } + else + { + pData->gifFrameStartTime = now; + } + + // Update lastPlayheadX to current position so timer ticks can track movement properly + { + HWND hTimeline = GetDlgItem(hDlg, IDC_TRIM_TIMELINE); + if (hTimeline) + { + RECT rc; + GetClientRect(hTimeline, &rc); + const UINT dpi = GetDpiForWindowHelper(hTimeline); + pData->lastPlayheadX = TimelineTimeToClientX(pData, pData->currentPosition, rc.right - rc.left, dpi); + } + } + + // Use multimedia timer for smooth GIF playback + if (!StartMMTimer(hDlg, pData)) + { + pData->isPlaying.store(false, std::memory_order_relaxed); + RefreshPlaybackButtons(hDlg); + co_return; + } + + PostMessage(hDlg, WMU_PLAYBACK_POSITION, 0, 0); + RefreshPlaybackButtons(hDlg); + co_return; + } + + // If a player already exists (paused), resume from the current playhead position. + if (pData->mediaPlayer) + { + // If the user already canceled playback, do nothing. + if (!pData->isPlaying.load(std::memory_order_acquire) || + pData->playbackCommandSerial.load(std::memory_order_acquire) != startSerial) + { + pData->isPlaying.store(false, std::memory_order_relaxed); + RefreshPlaybackButtons(hDlg); + co_return; + } + + try + { + auto session = pData->mediaPlayer.PlaybackSession(); + if (session) + { + // Resume from the current playhead position (do not change the loop anchor) + const int64_t clampedTicks = std::clamp(resumePosition.count(), 0, pData->trimEnd.count()); + session.Position(winrt::TimeSpan{ clampedTicks }); + pData->currentPosition = winrt::TimeSpan{ clampedTicks }; + // Defer smoothing until the first real media sample to avoid extrapolating from zero + pData->smoothActive.store(false, std::memory_order_relaxed); + pData->smoothHasNonZeroSample.store(false, std::memory_order_relaxed); + } + pData->mediaPlayer.Play(); + } + catch (...) + { + } + + // Use multimedia timer for smooth updates + if (!StartMMTimer(hDlg, pData)) + { + pData->isPlaying.store(false, std::memory_order_relaxed); + ResetSmoothPlayback(pData); + RefreshPlaybackButtons(hDlg); + co_return; + } + + // Update lastPlayheadX to current position so timer ticks can track movement properly + { + HWND hTimeline = GetDlgItem(hDlg, IDC_TRIM_TIMELINE); + if (hTimeline) + { + RECT rc; + GetClientRect(hTimeline, &rc); + const UINT dpi = GetDpiForWindowHelper(hTimeline); + pData->lastPlayheadX = TimelineTimeToClientX(pData, pData->currentPosition, rc.right - rc.left, dpi); + } + } + + PostMessage(hDlg, WMU_PLAYBACK_POSITION, 0, 0); + RefreshPlaybackButtons(hDlg); + co_return; + } + + CleanupMediaPlayer(pData); + + winrt::MediaPlayer newPlayer{ nullptr }; + + try + { + if (!pData->playbackFile) + { + auto file = co_await winrt::StorageFile::GetFileFromPathAsync(pData->videoPath); + pData->playbackFile = file; + } + + // The user may have clicked Pause while the async file lookup was in-flight. + if (!pData->isPlaying.load(std::memory_order_acquire) || + pData->playbackCommandSerial.load(std::memory_order_acquire) != startSerial) + { + pData->isPlaying.store(false, std::memory_order_relaxed); + RefreshPlaybackButtons(hDlg); + co_return; + } + + if (!pData->playbackFile) + { + throw winrt::hresult_error(E_FAIL); + } + + newPlayer = winrt::MediaPlayer(); + newPlayer.AudioCategory(winrt::MediaPlayerAudioCategory::Media); + newPlayer.IsVideoFrameServerEnabled(true); + newPlayer.AutoPlay(false); + newPlayer.Volume(pData->volume); + newPlayer.IsMuted(pData->volume == 0.0); + + pData->frameCopyInProgress.store(false, std::memory_order_relaxed); + pData->mediaPlayer = newPlayer; + + auto mediaSource = winrt::MediaSource::CreateFromStorageFile(pData->playbackFile); + VideoRecordingSession::TrimDialogData* dataPtr = pData; + + pData->frameAvailableToken = pData->mediaPlayer.VideoFrameAvailable([hDlg, dataPtr](auto const& sender, auto const&) + { + if (!dataPtr) + { + return; + } + + if (dataPtr->frameCopyInProgress.exchange(true, std::memory_order_relaxed)) + { + return; + } + + try + { + if (!EnsurePlaybackDevice(dataPtr)) + { + dataPtr->frameCopyInProgress.store(false, std::memory_order_relaxed); + return; + } + + auto session = sender.PlaybackSession(); + UINT width = session.NaturalVideoWidth(); + UINT height = session.NaturalVideoHeight(); + if (width == 0 || height == 0) + { + width = 640; + height = 360; + } + + if (!EnsureFrameTextures(dataPtr, width, height)) + { + dataPtr->frameCopyInProgress.store(false, std::memory_order_relaxed); + return; + } + + winrt::com_ptr dxgiSurface; + if (dataPtr->previewFrameTexture) + { + dxgiSurface = dataPtr->previewFrameTexture.as(); + } + + if (dxgiSurface) + { + winrt::com_ptr inspectableSurface; + if (SUCCEEDED(CreateDirect3D11SurfaceFromDXGISurface(dxgiSurface.get(), inspectableSurface.put()))) + { + auto surface = inspectableSurface.as(); + sender.CopyFrameToVideoSurface(surface); + + if (dataPtr->previewD3DContext && dataPtr->previewFrameStaging) + { + dataPtr->previewD3DContext->CopyResource(dataPtr->previewFrameStaging.get(), dataPtr->previewFrameTexture.get()); + + D3D11_MAPPED_SUBRESOURCE mapped{}; + if (SUCCEEDED(dataPtr->previewD3DContext->Map(dataPtr->previewFrameStaging.get(), 0, D3D11_MAP_READ, 0, &mapped))) + { + const UINT rowPitch = mapped.RowPitch; + const UINT bytesPerPixel = 4; + const UINT destStride = width * bytesPerPixel; + + BITMAPINFO bmi{}; + bmi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); + bmi.bmiHeader.biWidth = static_cast(width); + bmi.bmiHeader.biHeight = -static_cast(height); + bmi.bmiHeader.biPlanes = 1; + bmi.bmiHeader.biBitCount = 32; + bmi.bmiHeader.biCompression = BI_RGB; + + void* bits = nullptr; + HDC hdcScreen = GetDC(nullptr); + HBITMAP hBitmap = CreateDIBSection(hdcScreen, &bmi, DIB_RGB_COLORS, &bits, nullptr, 0); + ReleaseDC(nullptr, hdcScreen); + + if (hBitmap && bits) + { + BYTE* dest = static_cast(bits); + const BYTE* src = static_cast(mapped.pData); + for (UINT y = 0; y < height; ++y) + { + memcpy(dest + static_cast(y) * destStride, src + static_cast(y) * rowPitch, destStride); + } + + { + std::lock_guard lock(dataPtr->previewBitmapMutex); + if (dataPtr->hPreviewBitmap && dataPtr->previewBitmapOwned) + { + DeleteObject(dataPtr->hPreviewBitmap); + } + dataPtr->hPreviewBitmap = hBitmap; + dataPtr->previewBitmapOwned = true; + } + + PostMessage(hDlg, WMU_PREVIEW_READY, 0, 0); + } + else if (hBitmap) + { + DeleteObject(hBitmap); + } + + dataPtr->previewD3DContext->Unmap(dataPtr->previewFrameStaging.get(), 0); + } + } + } + } + } + catch (...) + { + } + + dataPtr->frameCopyInProgress.store(false, std::memory_order_relaxed); + }); + + auto session = pData->mediaPlayer.PlaybackSession(); + pData->positionChangedToken = session.PositionChanged([hDlg, dataPtr](auto const& sender, auto const&) + { + if (!dataPtr) + { + return; + } + + try + { + // When not playing, ignore media callbacks so UI-driven seeks remain authoritative. + if (!dataPtr->isPlaying.load(std::memory_order_relaxed)) + { + return; + } + + auto pos = sender.Position(); + + // Suppress the transient 0-position report before the initial seek takes effect. + if (dataPtr->pendingInitialSeek.load(std::memory_order_relaxed) && + dataPtr->pendingInitialSeekTicks.load(std::memory_order_relaxed) > 0 && + pos.count() == 0) + { + return; + } + + // First non-zero sample observed; allow normal updates. + if (pos.count() != 0) + { + dataPtr->pendingInitialSeek.store(false, std::memory_order_relaxed); + dataPtr->pendingInitialSeekTicks.store(0, std::memory_order_relaxed); + } + + // Check for end-of-clip BEFORE updating currentPosition to avoid + // storing a value >= trimEnd that could flash in the UI + if (pos >= dataPtr->trimEnd) + { + // Immediately mark as not playing to prevent further position updates + // before WMU_PLAYBACK_STOP is processed. + dataPtr->isPlaying.store(false, std::memory_order_release); +#if _DEBUG + OutputDebugStringW((L"[Trim] PositionChanged: pos >= trimEnd, posting stop. pos=" + + std::to_wstring(pos.count()) + L"\n").c_str()); +#endif + PostMessage(hDlg, WMU_PLAYBACK_STOP, 0, 0); + return; + } + + dataPtr->currentPosition = pos; + + if (dataPtr->isPlaying.load(std::memory_order_relaxed) && + !dataPtr->smoothHasNonZeroSample.load(std::memory_order_relaxed) && + pos.count() > 0) + { + // Seed smoothing on first real position, but keep baseline exact to avoid a jump + dataPtr->smoothHasNonZeroSample.store(true, std::memory_order_relaxed); + SyncSmoothPlayback(dataPtr, pos.count(), dataPtr->trimStart.count(), dataPtr->trimEnd.count()); + LogSmoothingEvent(L"eventFirst", pos.count(), pos.count(), 0); + } + + PostMessage(hDlg, WMU_PLAYBACK_POSITION, 0, 0); + } + catch (...) + { + } + }); + + pData->stateChangedToken = session.PlaybackStateChanged([hDlg](auto const&, auto const&) + { + PostMessage(hDlg, WMU_PLAYBACK_POSITION, 0, 0); + }); + + // Capture the resume position now since currentPosition may change before MediaOpened fires + const int64_t resumePositionTicks = std::clamp(resumePosition.count(), 0, pData->trimEnd.count()); +#if _DEBUG + OutputDebugStringW((L"[Trim] Setting up MediaOpened callback with resumePos=" + + std::to_wstring(resumePositionTicks) + L"\n").c_str()); +#endif + pData->mediaPlayer.MediaOpened([dataPtr, hDlg, resumePositionTicks, startSerial](auto const& sender, auto const&) + { + if (!dataPtr) + { + return; + } + try + { + if (!dataPtr->isPlaying.load(std::memory_order_acquire) || + dataPtr->playbackCommandSerial.load(std::memory_order_acquire) != startSerial) + { + sender.Pause(); + return; + } + // Seek to the captured resume position (loop anchor is stored separately) +#if _DEBUG + OutputDebugStringW((L"[Trim] MediaOpened: seeking to resumePos=" + + std::to_wstring(resumePositionTicks) + L"\n").c_str()); +#endif + sender.PlaybackSession().Position(winrt::TimeSpan{ resumePositionTicks }); + + // Re-check immediately before playing to reduce Play->Pause races. + if (!dataPtr->isPlaying.load(std::memory_order_acquire) || + dataPtr->playbackCommandSerial.load(std::memory_order_acquire) != startSerial) + { + sender.Pause(); + return; + } + sender.Play(); + + // Once MediaOpened has applied the initial seek, allow position updates again. + dataPtr->pendingInitialSeek.store(false, std::memory_order_relaxed); + dataPtr->pendingInitialSeekTicks.store(0, std::memory_order_relaxed); + } + catch (...) + { + } + }); + + pData->mediaPlayer.Source(mediaSource); + } + catch (...) + { + pData->isPlaying.store(false, std::memory_order_relaxed); + CleanupMediaPlayer(pData); + if (newPlayer) + { + try + { + newPlayer.Close(); + } + catch (...) + { + } + } + RefreshPlaybackButtons(hDlg); + co_return; + } + + // Use multimedia timer for smooth updates + if (!StartMMTimer(hDlg, pData)) + { + pData->isPlaying.store(false, std::memory_order_relaxed); + CleanupMediaPlayer(pData); + ResetSmoothPlayback(pData); + RefreshPlaybackButtons(hDlg); + co_return; + } + + // If a quick Pause happened right after Play, don't start timers/UI updates. + if (!pData->isPlaying.load(std::memory_order_acquire) || + pData->playbackCommandSerial.load(std::memory_order_acquire) != startSerial) + { + StopMMTimer(pData); + pData->isPlaying.store(false, std::memory_order_relaxed); + RefreshPlaybackButtons(hDlg); + co_return; + } + + // Defer smoothing until first real playback position is reported to prevent early extrapolation + pData->smoothActive.store(false, std::memory_order_relaxed); + pData->smoothHasNonZeroSample.store(false, std::memory_order_relaxed); + + // Update lastPlayheadX to current position so timer ticks can track movement properly + { + HWND hTimeline = GetDlgItem(hDlg, IDC_TRIM_TIMELINE); + if (hTimeline) + { + RECT rc; + GetClientRect(hTimeline, &rc); + const UINT dpi = GetDpiForWindowHelper(hTimeline); + pData->lastPlayheadX = TimelineTimeToClientX(pData, pData->currentPosition, rc.right - rc.left, dpi); + } + } + + PostMessage(hDlg, WMU_PLAYBACK_POSITION, 0, 0); + RefreshPlaybackButtons(hDlg); +} + +static LRESULT CALLBACK TimelineSubclassProc( + HWND hWnd, + UINT message, + WPARAM wParam, + LPARAM lParam, + UINT_PTR uIdSubclass, + DWORD_PTR dwRefData) +{ + auto* pData = reinterpret_cast(dwRefData); + if (!pData) + { + return DefSubclassProc(hWnd, message, wParam, lParam); + } + + auto restorePreviewIfNeeded = [&]() + { + if (!pData->restorePreviewOnRelease) + { + pData->previewOverrideActive = false; + pData->playheadPushed = false; + return; + } + + if (pData->playheadPushed) + { + // Keep pushed playhead; just clear override flags + pData->previewOverrideActive = false; + pData->restorePreviewOnRelease = false; + pData->playheadPushed = false; + return; + } + + if (pData->hDialog) + { + // Restore playhead to where it was before the gripper drag. + // Only clamp to video bounds, not selection bounds, so the playhead + // can remain outside the selection if it was there before. + const int64_t restoredTicks = std::clamp( + pData->positionBeforeOverride.count(), + 0LL, + pData->videoDuration.count()); + pData->currentPosition = winrt::TimeSpan{ restoredTicks }; + pData->previewOverrideActive = false; + pData->restorePreviewOnRelease = false; + pData->playheadPushed = false; + UpdateVideoPreview(pData->hDialog, pData); + } + }; + + switch (message) + { + case WM_NCDESTROY: + RemoveWindowSubclass(hWnd, TimelineSubclassProc, uIdSubclass); + break; + + case WM_LBUTTONDOWN: + { + // Pause without recapturing position; we might be parked on a handle + StopPlayback(pData->hDialog, pData, false); + + RECT rcClient{}; + GetClientRect(hWnd, &rcClient); + const int width = rcClient.right - rcClient.left; + if (width <= 0) + { + break; + } + + const int x = GET_X_LPARAM(lParam); + const int y = GET_Y_LPARAM(lParam); + const int clampedX = std::clamp(x, 0, width); + + // Get DPI for scaling hit test regions + const UINT dpi = GetDpiForWindowHelper(hWnd); + const int timelineTrackTopOffset = ScaleForDpi(kTimelineTrackTopOffset, dpi); + const int timelineTrackHeight = ScaleForDpi(kTimelineTrackHeight, dpi); + const int timelineHandleHeight = ScaleForDpi(kTimelineHandleHeight, dpi); + const int timelineHandleHitRadius = ScaleForDpi(kTimelineHandleHitRadius, dpi); + + const int trackTop = timelineTrackTopOffset; + const int trackBottom = trackTop + timelineTrackHeight; + + // Gripper vertical band: centered on track + const int gripperTop = trackTop - (timelineHandleHeight - timelineTrackHeight) / 2; + const int gripperBottom = gripperTop + timelineHandleHeight; + const bool inGripperBand = (y >= gripperTop && y <= gripperBottom); + + // Playhead knob vertical band: below the track (ellipse drawn at trackBottom + 12 to trackBottom + 24) + const int knobTop = trackBottom + ScaleForDpi(8, dpi); // slightly above ellipse for easier hit + const int knobBottom = trackBottom + ScaleForDpi(28, dpi); + const bool inKnobBand = (y >= knobTop && y <= knobBottom); + + // Playhead stem is also hittable (trackTop - 12 to trackBottom + posLineBelow) + const int stemTop = trackTop - ScaleForDpi(12, dpi); + const int stemBottom = trackBottom + ScaleForDpi(22, dpi); + const bool inStemBand = (y >= stemTop && y <= stemBottom); + + const int startX = TimelineTimeToClientX(pData, pData->trimStart, width, dpi); + const int posX = TimelineTimeToClientX(pData, pData->currentPosition, width, dpi); + const int endX = TimelineTimeToClientX(pData, pData->trimEnd, width, dpi); + + pData->dragMode = VideoRecordingSession::TrimDialogData::None; + pData->previewOverrideActive = false; + pData->restorePreviewOnRelease = false; + + // Calculate horizontal distances to each handle + const int distToPos = abs(clampedX - posX); + const int distToStart = abs(clampedX - startX); + const int distToEnd = abs(clampedX - endX); + + // Hit-test with vertical position awareness: + // - Grippers are only hittable in the gripper band (around the track) + // - Playhead is hittable in the knob band (below track) or stem band + // - When clicking in the knob area (below track), playhead always wins + // - When in the gripper band, grippers take priority for horizontal overlaps + + const bool startHit = inGripperBand && distToStart <= timelineHandleHitRadius; + const bool endHit = inGripperBand && distToEnd <= timelineHandleHitRadius; + const bool posHitKnob = inKnobBand && distToPos <= timelineHandleHitRadius; + const bool posHitStem = inStemBand && distToPos <= ScaleForDpi(4, dpi); // tighter radius for stem + + // Prioritize playhead when clicking in the knob area (lollipop head below the track) + if (posHitKnob) + { + pData->dragMode = VideoRecordingSession::TrimDialogData::Position; + } + else if (startHit && (!endHit || distToStart <= distToEnd)) + { + pData->dragMode = VideoRecordingSession::TrimDialogData::TrimStart; + } + else if (endHit) + { + pData->dragMode = VideoRecordingSession::TrimDialogData::TrimEnd; + } + else if (posHitStem) + { + pData->dragMode = VideoRecordingSession::TrimDialogData::Position; + } + + if (pData->dragMode != VideoRecordingSession::TrimDialogData::None) + { + pData->isDragging = true; + pData->playheadPushed = false; + if (pData->dragMode == VideoRecordingSession::TrimDialogData::TrimStart || + pData->dragMode == VideoRecordingSession::TrimDialogData::TrimEnd) + { + pData->positionBeforeOverride = pData->currentPosition; + pData->previewOverrideActive = true; + pData->restorePreviewOnRelease = true; + pData->previewOverride = (pData->dragMode == VideoRecordingSession::TrimDialogData::TrimStart) ? + pData->trimStart : pData->trimEnd; + UpdateVideoPreview(pData->hDialog, pData); + // Show resize cursor during grip drag + SetCursor(LoadCursor(nullptr, IDC_SIZEWE)); + } + SetCapture(hWnd); + return 0; + } + break; + } + + case WM_LBUTTONUP: + { + if (pData->isDragging) + { + // Kill debounce timer and do immediate final update + KillTimer(hWnd, kPreviewDebounceTimerId); + const bool wasPositionDrag = (pData->dragMode == VideoRecordingSession::TrimDialogData::Position); + pData->isDragging = false; + ReleaseCapture(); + SetCursor(LoadCursor(nullptr, IDC_ARROW)); + restorePreviewIfNeeded(); + pData->dragMode = VideoRecordingSession::TrimDialogData::None; + InvalidateRect(hWnd, nullptr, FALSE); + // Ensure final preview update for playhead drag (restorePreviewIfNeeded doesn't update for this case) + if (wasPositionDrag && pData->hDialog) + { + UpdateVideoPreview(pData->hDialog, pData, false); + } + return 0; + } + break; + } + + case WM_MOUSEMOVE: + { + TRACKMOUSEEVENT tme{}; + tme.cbSize = sizeof(tme); + tme.dwFlags = TME_LEAVE; + tme.hwndTrack = hWnd; + TrackMouseEvent(&tme); + + RECT rcClient{}; + GetClientRect(hWnd, &rcClient); + const int width = rcClient.right - rcClient.left; + if (width <= 0) + { + break; + } + + const int rawX = GET_X_LPARAM(lParam); + const int clampedX = std::clamp(rawX, 0, width); + + if (!pData->isDragging) + { + // Get DPI for scaling hit test regions + const UINT dpi = GetDpiForWindowHelper(hWnd); + const int timelineHandleHitRadius = ScaleForDpi(kTimelineHandleHitRadius, dpi); + + const int startX = TimelineTimeToClientX(pData, pData->trimStart, width, dpi); + const int posX = TimelineTimeToClientX(pData, pData->currentPosition, width, dpi); + const int endX = TimelineTimeToClientX(pData, pData->trimEnd, width, dpi); + + if (abs(clampedX - posX) <= timelineHandleHitRadius) + { + SetCursor(LoadCursor(nullptr, IDC_HAND)); + } + else if (abs(clampedX - startX) < timelineHandleHitRadius || abs(clampedX - endX) < timelineHandleHitRadius) + { + SetCursor(LoadCursor(nullptr, IDC_HAND)); + } + else + { + SetCursor(LoadCursor(nullptr, IDC_ARROW)); + } + return 0; + } + + // Set appropriate cursor during drag + if (pData->dragMode == VideoRecordingSession::TrimDialogData::TrimStart || + pData->dragMode == VideoRecordingSession::TrimDialogData::TrimEnd) + { + SetCursor(LoadCursor(nullptr, IDC_SIZEWE)); + } + else if (pData->dragMode == VideoRecordingSession::TrimDialogData::Position) + { + SetCursor(LoadCursor(nullptr, IDC_HAND)); + } + + // Get DPI for pixel-to-time conversion during drag + const UINT dpi = GetDpiForWindowHelper(hWnd); + const auto newTime = TimelinePixelToTime(pData, clampedX, width, dpi); + + bool requestPreviewUpdate = false; + bool applyOverride = false; + winrt::TimeSpan overrideTime{ 0 }; + + switch (pData->dragMode) + { + case VideoRecordingSession::TrimDialogData::TrimStart: + if (newTime.count() < pData->trimEnd.count()) + { + const auto oldTrimStart = pData->trimStart; + if (newTime.count() != pData->trimStart.count()) + { + pData->trimStart = newTime; + UpdateDurationDisplay(pData->hDialog, pData); + } + // Push playhead if gripper crossed over it in either direction: + // - Moving right: playhead was >= oldTrimStart and is now < newTrimStart + // - Moving left: playhead was <= oldTrimStart and is now >= newTrimStart + // (use <= so that once pushed, the playhead continues moving with the gripper) + const bool movingRight = pData->trimStart.count() > oldTrimStart.count(); + const bool movingLeft = pData->trimStart.count() < oldTrimStart.count(); + const bool pushRight = movingRight && + pData->currentPosition.count() >= oldTrimStart.count() && + pData->currentPosition.count() < pData->trimStart.count(); + const bool pushLeft = movingLeft && + pData->currentPosition.count() <= oldTrimStart.count() && + pData->currentPosition.count() >= pData->trimStart.count(); + if (pushRight || pushLeft) + { + pData->playheadPushed = true; + pData->currentPosition = pData->trimStart; + // Also update playback start position so loop resets to pushed position + pData->playbackStartPosition = pData->currentPosition; + pData->playbackStartPositionValid = true; + // Invalidate cached start frame + std::lock_guard lock(pData->previewBitmapMutex); + if (pData->hCachedStartFrame) + { + DeleteObject(pData->hCachedStartFrame); + pData->hCachedStartFrame = nullptr; + } + } + overrideTime = pData->trimStart; + applyOverride = true; + requestPreviewUpdate = true; + } + break; + + case VideoRecordingSession::TrimDialogData::Position: + { + const int previousPosX = TimelineTimeToClientX(pData, pData->currentPosition, width, dpi); + + // Allow playhead to move anywhere within video bounds (0 to videoDuration) + const int64_t clampedTicks = std::clamp(newTime.count(), 0LL, pData->videoDuration.count()); + pData->currentPosition = winrt::TimeSpan{ clampedTicks }; + + // User explicitly positioned the playhead; update the loop anchor. + pData->playbackStartPosition = pData->currentPosition; + pData->playbackStartPositionValid = true; + + // Invalidate cached start frame since position changed - will be re-cached when playback starts. + { + std::lock_guard lock(pData->previewBitmapMutex); + if (pData->hCachedStartFrame) + { + DeleteObject(pData->hCachedStartFrame); + pData->hCachedStartFrame = nullptr; + } + } + + const int newPosX = TimelineTimeToClientX(pData, pData->currentPosition, width, dpi); + RECT clientRect{}; + GetClientRect(hWnd, &clientRect); + InvalidatePlayheadRegion(hWnd, clientRect, previousPosX, newPosX, dpi); + UpdateWindow(hWnd); // Force immediate visual update for smooth dragging + pData->previewOverrideActive = false; + // Debounce preview update for playhead drag as well + SetTimer(hWnd, kPreviewDebounceTimerId, kPreviewDebounceDelayMs, nullptr); + break; + } + + case VideoRecordingSession::TrimDialogData::TrimEnd: + if (newTime.count() > pData->trimStart.count()) + { + const auto oldTrimEnd = pData->trimEnd; + if (newTime.count() != pData->trimEnd.count()) + { + pData->trimEnd = newTime; + UpdateDurationDisplay(pData->hDialog, pData); + } + // Only push playhead if it was inside selection (<= old trimEnd) and handle crossed over it + if (pData->currentPosition.count() <= oldTrimEnd.count() && + pData->currentPosition.count() > pData->trimEnd.count()) + { + pData->playheadPushed = true; + pData->currentPosition = pData->trimEnd; + // Also update playback start position so loop resets to pushed position + pData->playbackStartPosition = pData->currentPosition; + pData->playbackStartPositionValid = true; + // Invalidate cached start frame + std::lock_guard lock(pData->previewBitmapMutex); + if (pData->hCachedStartFrame) + { + DeleteObject(pData->hCachedStartFrame); + pData->hCachedStartFrame = nullptr; + } + } + overrideTime = pData->trimEnd; + applyOverride = true; + requestPreviewUpdate = true; + } + break; + + default: + break; + } + + if (applyOverride) + { + pData->previewOverrideActive = true; + pData->previewOverride = overrideTime; + } + + // Force immediate visual update of gripper for smooth dragging + InvalidateRect(hWnd, nullptr, FALSE); + UpdateWindow(hWnd); + + // Debounce preview update - use a timer to avoid overwhelming the system with requests + // Each mouse move resets the timer; preview only updates after dragging pauses + if (requestPreviewUpdate) + { + SetTimer(hWnd, kPreviewDebounceTimerId, kPreviewDebounceDelayMs, nullptr); + } + + return 0; + } + + case WM_TIMER: + { + if (wParam == kPreviewDebounceTimerId) + { + KillTimer(hWnd, kPreviewDebounceTimerId); + if (pData && pData->hDialog) + { + UpdateVideoPreview(pData->hDialog, pData, false); + } + return 0; + } + break; + } + + case WM_ERASEBKGND: + return 1; + + case WM_MOUSELEAVE: + if (!pData->isDragging) + { + SetCursor(LoadCursor(nullptr, IDC_ARROW)); + } + break; + + case WM_CAPTURECHANGED: + if (pData->isDragging) + { + KillTimer(hWnd, kPreviewDebounceTimerId); + pData->isDragging = false; + pData->dragMode = VideoRecordingSession::TrimDialogData::None; + restorePreviewIfNeeded(); + } + break; + } + + return DefSubclassProc(hWnd, message, wParam, lParam); +} + +//---------------------------------------------------------------------------- +// +// Helper: Draw custom playback buttons (play/pause and restart) +// +//---------------------------------------------------------------------------- +static void DrawPlaybackButton( + const DRAWITEMSTRUCT* pDIS, + VideoRecordingSession::TrimDialogData* pData) +{ + if (!pDIS || !pData) + { + return; + } + + const bool isPlayControl = (pDIS->CtlID == IDC_TRIM_PLAY_PAUSE); + const bool isRewindControl = (pDIS->CtlID == IDC_TRIM_REWIND); + const bool isForwardControl = (pDIS->CtlID == IDC_TRIM_FORWARD); + const bool isSkipStartControl = (pDIS->CtlID == IDC_TRIM_SKIP_START); + const bool isSkipEndControl = (pDIS->CtlID == IDC_TRIM_SKIP_END); + + // Check if skip buttons should be disabled based on position + const bool atStart = (pData->currentPosition.count() <= pData->trimStart.count()); + const bool atEnd = (pData->currentPosition.count() >= pData->trimEnd.count()); + + const bool isHover = isPlayControl ? pData->hoverPlay : + (isRewindControl ? pData->hoverRewind : + (isForwardControl ? pData->hoverForward : + (isSkipStartControl ? pData->hoverSkipStart : pData->hoverSkipEnd))); + bool isDisabled = (pDIS->itemState & ODS_DISABLED) != 0; + + // Disable skip start when at start, skip end when at end + if (isSkipStartControl && atStart) isDisabled = true; + if (isSkipEndControl && atEnd) isDisabled = true; + + const bool isPressed = (pDIS->itemState & ODS_SELECTED) != 0; + const bool isPlaying = pData->isPlaying.load(std::memory_order_relaxed); + + // Media Player color scheme - dark background with gradient + COLORREF bgColorTop = RGB(45, 45, 50); + COLORREF bgColorBottom = RGB(35, 35, 40); + COLORREF iconColor = RGB(220, 220, 220); + COLORREF borderColor = RGB(120, 120, 125); + + if (isHover && !isDisabled) + { + bgColorTop = RGB(60, 60, 65); + bgColorBottom = RGB(50, 50, 55); + iconColor = RGB(255, 255, 255); + borderColor = RGB(150, 150, 155); + } + if (isPressed && !isDisabled) + { + bgColorTop = RGB(30, 30, 35); + bgColorBottom = RGB(25, 25, 30); + iconColor = RGB(200, 200, 200); + } + if (isDisabled) + { + bgColorTop = RGB(40, 40, 45); + bgColorBottom = RGB(35, 35, 40); + iconColor = RGB(100, 100, 100); + } + + int width = pDIS->rcItem.right - pDIS->rcItem.left; + int height = pDIS->rcItem.bottom - pDIS->rcItem.top; + float centerX = pDIS->rcItem.left + width / 2.0f; + float centerY = pDIS->rcItem.top + height / 2.0f; + float radius = min(width, height) / 2.0f - 1.0f; + + // Use GDI+ for antialiased rendering + Gdiplus::Graphics graphics(pDIS->hDC); + graphics.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias); + + // Draw flat background circle (no gradient) + Gdiplus::SolidBrush bgBrush(Gdiplus::Color(255, GetRValue(bgColorBottom), GetGValue(bgColorBottom), GetBValue(bgColorBottom))); + graphics.FillEllipse(&bgBrush, centerX - radius, centerY - radius, radius * 2, radius * 2); + + // Draw subtle border + Gdiplus::Pen borderPen(Gdiplus::Color(100, GetRValue(borderColor), GetGValue(borderColor), GetBValue(borderColor)), 0.5f); + graphics.DrawEllipse(&borderPen, centerX - radius, centerY - radius, radius * 2, radius * 2); + + // Draw icons + Gdiplus::SolidBrush iconBrush(Gdiplus::Color(255, GetRValue(iconColor), GetGValue(iconColor), GetBValue(iconColor))); + float iconSize = radius * 0.8f; // slightly larger icons + + if (isPlayControl) + { + if (isPlaying) + { + // Draw pause icon (two vertical bars) + float barWidth = iconSize / 4.0f; + float barHeight = iconSize; + float gap = iconSize / 5.0f; + + graphics.FillRectangle(&iconBrush, + centerX - gap - barWidth, centerY - barHeight / 2.0f, + barWidth, barHeight); + graphics.FillRectangle(&iconBrush, + centerX + gap, centerY - barHeight / 2.0f, + barWidth, barHeight); + } + else + { + // Draw play triangle + float triWidth = iconSize; + float triHeight = iconSize; + Gdiplus::PointF playTri[3] = { + Gdiplus::PointF(centerX - triWidth / 3.0f, centerY - triHeight / 2.0f), + Gdiplus::PointF(centerX + triWidth * 2.0f / 3.0f, centerY), + Gdiplus::PointF(centerX - triWidth / 3.0f, centerY + triHeight / 2.0f) + }; + graphics.FillPolygon(&iconBrush, playTri, 3); + } + } + else if (isRewindControl || isForwardControl) + { + // Draw small play triangle in appropriate direction + float triWidth = iconSize * 3.0f / 5.0f; + float triHeight = iconSize * 3.0f / 5.0f; + + if (isRewindControl) + { + // Triangle pointing left + Gdiplus::PointF tri[3] = { + Gdiplus::PointF(centerX + triWidth / 3.0f, centerY - triHeight / 2.0f), + Gdiplus::PointF(centerX - triWidth * 2.0f / 3.0f, centerY), + Gdiplus::PointF(centerX + triWidth / 3.0f, centerY + triHeight / 2.0f) + }; + graphics.FillPolygon(&iconBrush, tri, 3); + } + else + { + // Triangle pointing right + Gdiplus::PointF tri[3] = { + Gdiplus::PointF(centerX - triWidth / 3.0f, centerY - triHeight / 2.0f), + Gdiplus::PointF(centerX + triWidth * 2.0f / 3.0f, centerY), + Gdiplus::PointF(centerX - triWidth / 3.0f, centerY + triHeight / 2.0f) + }; + graphics.FillPolygon(&iconBrush, tri, 3); + } + } + else if (isSkipStartControl || isSkipEndControl) + { + // Draw skip to start/end icon (triangle + bar) + float triWidth = iconSize * 2.0f / 3.0f; + float triHeight = iconSize; + float barWidth = iconSize / 6.0f; + + if (isSkipStartControl) + { + // Bar on left, triangle pointing left + graphics.FillRectangle(&iconBrush, + centerX - triWidth / 2.0f - barWidth, centerY - triHeight / 2.0f, + barWidth, triHeight); + + Gdiplus::PointF tri[3] = { + Gdiplus::PointF(centerX + triWidth / 2.0f, centerY - triHeight / 2.0f), + Gdiplus::PointF(centerX - triWidth / 2.0f, centerY), + Gdiplus::PointF(centerX + triWidth / 2.0f, centerY + triHeight / 2.0f) + }; + graphics.FillPolygon(&iconBrush, tri, 3); + } + else + { + // Triangle pointing right, bar on right + Gdiplus::PointF tri[3] = { + Gdiplus::PointF(centerX - triWidth / 2.0f, centerY - triHeight / 2.0f), + Gdiplus::PointF(centerX + triWidth / 2.0f, centerY), + Gdiplus::PointF(centerX - triWidth / 2.0f, centerY + triHeight / 2.0f) + }; + graphics.FillPolygon(&iconBrush, tri, 3); + + graphics.FillRectangle(&iconBrush, + centerX + triWidth / 2.0f, centerY - triHeight / 2.0f, + barWidth, triHeight); + } + } +} + +//---------------------------------------------------------------------------- +// +// Helper: Mouse interaction for volume icon +// +//---------------------------------------------------------------------------- +static LRESULT CALLBACK VolumeIconSubclassProc( + HWND hWnd, + UINT message, + WPARAM wParam, + LPARAM lParam, + UINT_PTR uIdSubclass, + DWORD_PTR dwRefData) +{ + auto* pData = reinterpret_cast(dwRefData); + if (!pData) + { + return DefSubclassProc(hWnd, message, wParam, lParam); + } + + switch (message) + { + case WM_NCDESTROY: + RemoveWindowSubclass(hWnd, VolumeIconSubclassProc, uIdSubclass); + break; + + case WM_MOUSEMOVE: + { + TRACKMOUSEEVENT tme{ sizeof(tme), TME_LEAVE, hWnd, 0 }; + TrackMouseEvent(&tme); + + if (!pData->hoverVolumeIcon) + { + pData->hoverVolumeIcon = true; + InvalidateRect(hWnd, nullptr, FALSE); + } + return 0; + } + + case WM_MOUSELEAVE: + if (pData->hoverVolumeIcon) + { + pData->hoverVolumeIcon = false; + InvalidateRect(hWnd, nullptr, FALSE); + } + return 0; + + case WM_SETCURSOR: + SetCursor(LoadCursor(nullptr, IDC_HAND)); + return TRUE; + } + + return DefSubclassProc(hWnd, message, wParam, lParam); +} + +//---------------------------------------------------------------------------- +// +// Helper: Mouse interaction for playback controls +// +//---------------------------------------------------------------------------- +static LRESULT CALLBACK PlaybackButtonSubclassProc( + HWND hWnd, + UINT message, + WPARAM wParam, + LPARAM lParam, + UINT_PTR uIdSubclass, + DWORD_PTR dwRefData) +{ + auto* pData = reinterpret_cast(dwRefData); + if (!pData) + { + return DefSubclassProc(hWnd, message, wParam, lParam); + } + + switch (message) + { + case WM_NCDESTROY: + RemoveWindowSubclass(hWnd, PlaybackButtonSubclassProc, uIdSubclass); + break; + + case WM_LBUTTONDOWN: + SetFocus(hWnd); + SetCapture(hWnd); + return 0; + + case WM_LBUTTONUP: + { + if (GetCapture() == hWnd) + { + ReleaseCapture(); + } + + POINT pt{ GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam) }; + RECT rc{}; + GetClientRect(hWnd, &rc); + + if (PtInRect(&rc, pt)) + { + HandlePlaybackCommand(GetDlgCtrlID(hWnd), pData); + } + return 0; + } + + case WM_KEYUP: + if (wParam == VK_SPACE || wParam == VK_RETURN) + { + HandlePlaybackCommand(GetDlgCtrlID(hWnd), pData); + return 0; + } + break; + + case WM_MOUSEMOVE: + { + TRACKMOUSEEVENT tme{ sizeof(tme), TME_LEAVE, hWnd, 0 }; + TrackMouseEvent(&tme); + + const int controlId = GetDlgCtrlID(hWnd); + const bool isPlayControl = (controlId == IDC_TRIM_PLAY_PAUSE); + const bool isRewindControl = (controlId == IDC_TRIM_REWIND); + const bool isForwardControl = (controlId == IDC_TRIM_FORWARD); + const bool isSkipStartControl = (controlId == IDC_TRIM_SKIP_START); + + bool& hoverFlag = isPlayControl ? pData->hoverPlay : + (isRewindControl ? pData->hoverRewind : + (isForwardControl ? pData->hoverForward : + (isSkipStartControl ? pData->hoverSkipStart : pData->hoverSkipEnd))); + if (!hoverFlag) + { + hoverFlag = true; + InvalidateRect(hWnd, nullptr, FALSE); + } + return 0; + } + + case WM_MOUSELEAVE: + { + const int controlId = GetDlgCtrlID(hWnd); + const bool isPlayControl = (controlId == IDC_TRIM_PLAY_PAUSE); + const bool isRewindControl = (controlId == IDC_TRIM_REWIND); + const bool isForwardControl = (controlId == IDC_TRIM_FORWARD); + const bool isSkipStartControl = (controlId == IDC_TRIM_SKIP_START); + + bool& hoverFlag = isPlayControl ? pData->hoverPlay : + (isRewindControl ? pData->hoverRewind : + (isForwardControl ? pData->hoverForward : + (isSkipStartControl ? pData->hoverSkipStart : pData->hoverSkipEnd))); + if (hoverFlag) + { + hoverFlag = false; + InvalidateRect(hWnd, nullptr, FALSE); + } + return 0; + } + + case WM_SETCURSOR: + SetCursor(LoadCursor(nullptr, IDC_HAND)); + return TRUE; + + case WM_ERASEBKGND: + return 1; + + } + + return DefSubclassProc(hWnd, message, wParam, lParam); +} + +//---------------------------------------------------------------------------- +// +// TrimDialogSubclassProc +// +// Subclass procedure for the trim dialog to handle resize grip hit testing +// +//---------------------------------------------------------------------------- +static LRESULT CALLBACK TrimDialogSubclassProc( + HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam, + UINT_PTR uIdSubclass, DWORD_PTR /*dwRefData*/) +{ + switch (message) + { + case WM_NCDESTROY: + RemoveWindowSubclass(hWnd, TrimDialogSubclassProc, uIdSubclass); + break; + + case WM_NCHITTEST: + { + // First let the default handler process it + LRESULT ht = DefSubclassProc(hWnd, message, wParam, lParam); + + // If it's in the client area and not maximized, check for resize grip + if (ht == HTCLIENT && !IsZoomed(hWnd)) + { + RECT rcClient; + GetClientRect(hWnd, &rcClient); + + POINT pt = { GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam) }; + ScreenToClient(hWnd, &pt); + + const int gripWidth = GetSystemMetrics(SM_CXHSCROLL); + const int gripHeight = GetSystemMetrics(SM_CYVSCROLL); + + if (pt.x >= rcClient.right - gripWidth && pt.y >= rcClient.bottom - gripHeight) + { + return HTBOTTOMRIGHT; + } + } + return ht; + } + } + + return DefSubclassProc(hWnd, message, wParam, lParam); +} + +//---------------------------------------------------------------------------- +// +// VideoRecordingSession::TrimDialogProc +// +// Dialog procedure for trim dialog +// +//---------------------------------------------------------------------------- +INT_PTR CALLBACK VideoRecordingSession::TrimDialogProc(HWND hDlg, UINT message, WPARAM wParam, LPARAM lParam) +{ + static TrimDialogData* pData = nullptr; + static UINT currentDpi = DPI_BASELINE; + + switch (message) + { + case WM_INITDIALOG: + { + pData = reinterpret_cast(lParam); + if (!pData) + { + EndDialog(hDlg, IDCANCEL); + return FALSE; + } + + hDlgTrimDialog = hDlg; + SetWindowLongPtr(hDlg, DWLP_USER, lParam); + + pData->hDialog = hDlg; + pData->hoverPlay = false; + pData->hoverRewind = false; + pData->hoverForward = false; + pData->hoverSkipStart = false; + pData->hoverSkipEnd = false; + pData->isPlaying.store(false, std::memory_order_relaxed); + pData->lastRenderedPreview.store(-1, std::memory_order_relaxed); + + AcquireHighResTimer(); + + // Make OK the default button + SendMessage(hDlg, DM_SETDEFID, IDOK, 0); + + // Subclass the dialog to handle resize grip hit testing + SetWindowSubclass(hDlg, TrimDialogSubclassProc, 0, reinterpret_cast(pData)); + + HWND hTimeline = GetDlgItem(hDlg, IDC_TRIM_TIMELINE); + if (hTimeline) + { + // Remove WS_EX_TRANSPARENT to prevent flicker during resize + SetWindowLongPtr(hTimeline, GWL_EXSTYLE, GetWindowLongPtr(hTimeline, GWL_EXSTYLE) & ~WS_EX_TRANSPARENT); + SetWindowSubclass(hTimeline, TimelineSubclassProc, 1, reinterpret_cast(pData)); + } + HWND hPlayPause = GetDlgItem(hDlg, IDC_TRIM_PLAY_PAUSE); + if (hPlayPause) + { + SetWindowSubclass(hPlayPause, PlaybackButtonSubclassProc, 2, reinterpret_cast(pData)); + } + HWND hRewind = GetDlgItem(hDlg, IDC_TRIM_REWIND); + if (hRewind) + { + SetWindowSubclass(hRewind, PlaybackButtonSubclassProc, 3, reinterpret_cast(pData)); + } + HWND hForward = GetDlgItem(hDlg, IDC_TRIM_FORWARD); + if (hForward) + { + SetWindowSubclass(hForward, PlaybackButtonSubclassProc, 4, reinterpret_cast(pData)); + } + HWND hSkipStart = GetDlgItem(hDlg, IDC_TRIM_SKIP_START); + if (hSkipStart) + { + SetWindowSubclass(hSkipStart, PlaybackButtonSubclassProc, 5, reinterpret_cast(pData)); + } + HWND hSkipEnd = GetDlgItem(hDlg, IDC_TRIM_SKIP_END); + if (hSkipEnd) + { + SetWindowSubclass(hSkipEnd, PlaybackButtonSubclassProc, 6, reinterpret_cast(pData)); + } + HWND hVolumeIcon = GetDlgItem(hDlg, IDC_TRIM_VOLUME_ICON); + if (hVolumeIcon) + { + SetWindowSubclass(hVolumeIcon, VolumeIconSubclassProc, 7, reinterpret_cast(pData)); + } + + // Initialize volume from saved setting + pData->volume = std::clamp(static_cast(g_TrimDialogVolume) / 100.0, 0.0, 1.0); + pData->previousVolume = (pData->volume > 0.0) ? pData->volume : 0.70; // Remember initial volume for unmute + + // Initialize volume slider + HWND hVolume = GetDlgItem(hDlg, IDC_TRIM_VOLUME); + if (hVolume) + { + SendMessage(hVolume, TBM_SETRANGE, TRUE, MAKELPARAM(0, 100)); + SendMessage(hVolume, TBM_SETPOS, TRUE, static_cast(pData->volume * 100)); + } + + // Hide volume controls for GIF (no audio) + if (pData->isGif) + { + if (hVolumeIcon) + { + ShowWindow(hVolumeIcon, SW_HIDE); + } + if (hVolume) + { + ShowWindow(hVolume, SW_HIDE); + } + } + + // Ensure incoming times are sane and within bounds. + if (pData->videoDuration.count() > 0) + { + const int64_t durationTicks = pData->videoDuration.count(); + const int64_t endTicks = (pData->trimEnd.count() > 0) ? pData->trimEnd.count() : durationTicks; + const int64_t clampedEnd = std::clamp(endTicks, 0, durationTicks); + const int64_t clampedStart = std::clamp(pData->trimStart.count(), 0, clampedEnd); + pData->trimStart = winrt::TimeSpan{ clampedStart }; + pData->trimEnd = winrt::TimeSpan{ clampedEnd }; + } + + // Keep the playhead at a valid position. + const int64_t upper = (pData->trimEnd.count() > 0) ? pData->trimEnd.count() : pData->videoDuration.count(); + pData->currentPosition = winrt::TimeSpan{ std::clamp(pData->currentPosition.count(), 0, upper) }; + + UpdateDurationDisplay(hDlg, pData); + + // Update labels and timeline; skip async preview load if we already have a preloaded frame + if (pData->hPreviewBitmap) + { + // Already have a preview from preloading - just update the UI + UpdatePositionUI(hDlg, pData, true); + InvalidateRect(GetDlgItem(hDlg, IDC_TRIM_PREVIEW), nullptr, FALSE); + } + else + { + // No preloaded preview - start async video load + UpdateVideoPreview(hDlg, pData); + } + // Show time relative to left grip (trimStart) + const auto relativePos = winrt::TimeSpan{ (std::max)(pData->currentPosition.count() - pData->trimStart.count(), int64_t{ 0 }) }; + SetTimeText(hDlg, IDC_TRIM_POSITION_LABEL, relativePos, true); + + // Initialize currentDpi to actual dialog DPI (for WM_DPICHANGED handling) + currentDpi = GetDpiForWindowHelper(hDlg); + + // Create a larger font for the time position label + { + int fontSize = -MulDiv(12, static_cast(currentDpi), USER_DEFAULT_SCREEN_DPI); // 12pt font + pData->hTimeLabelFont = CreateFont(fontSize, 0, 0, 0, FW_NORMAL, FALSE, FALSE, FALSE, DEFAULT_CHARSET, + OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, CLEARTYPE_QUALITY, DEFAULT_PITCH | FF_DONTCARE, L"Segoe UI"); + if (pData->hTimeLabelFont) + { + HWND hPosition = GetDlgItem(hDlg, IDC_TRIM_POSITION_LABEL); + if (hPosition) + { + SendMessage(hPosition, WM_SETFONT, reinterpret_cast(pData->hTimeLabelFont), TRUE); + } + HWND hDuration = GetDlgItem(hDlg, IDC_TRIM_DURATION_LABEL); + if (hDuration) + { + SendMessage(hDuration, WM_SETFONT, reinterpret_cast(pData->hTimeLabelFont), TRUE); + } + } + } + + // Apply dark mode + ApplyDarkModeToDialog( hDlg ); + + // Apply saved dialog size if available, then center + if (g_TrimDialogWidth > 0 && g_TrimDialogHeight > 0) + { + // Get current window rect to preserve position initially + RECT rcDlg{}; + GetWindowRect(hDlg, &rcDlg); + + // Apply saved size (stored in screen pixels) + SetWindowPos(hDlg, nullptr, 0, 0, + static_cast(g_TrimDialogWidth), + static_cast(g_TrimDialogHeight), + SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE); + } + + // Center dialog on screen + CenterTrimDialog(hDlg); + return TRUE; + } + + case WM_CTLCOLORDLG: + case WM_CTLCOLORBTN: + case WM_CTLCOLOREDIT: + case WM_CTLCOLORLISTBOX: + { + HDC hdc = reinterpret_cast(wParam); + HWND hCtrl = reinterpret_cast(lParam); + HBRUSH hBrush = HandleDarkModeCtlColor(hdc, hCtrl, message); + if (hBrush) + { + return reinterpret_cast(hBrush); + } + break; + } + + case WM_CTLCOLORSTATIC: + { + HDC hdc = reinterpret_cast(wParam); + HWND hCtrl = reinterpret_cast(lParam); + // Use timeline marker color for duration and position labels + if (IsDarkModeEnabled()) + { + int ctrlId = GetDlgCtrlID(hCtrl); + if (ctrlId == IDC_TRIM_DURATION_LABEL || ctrlId == IDC_TRIM_POSITION_LABEL) + { + SetBkMode(hdc, TRANSPARENT); + SetTextColor(hdc, RGB(140, 140, 140)); // Match timeline marker color + return reinterpret_cast(GetDarkModeBrush()); + } + } + HBRUSH hBrush = HandleDarkModeCtlColor(hdc, hCtrl, message); + if (hBrush) + { + return reinterpret_cast(hBrush); + } + break; + } + + case WM_ERASEBKGND: + if (IsDarkModeEnabled()) + { + HDC hdc = reinterpret_cast(wParam); + RECT rc; + GetClientRect(hDlg, &rc); + FillRect(hdc, &rc, GetDarkModeBrush()); + + // Draw the resize grip at the bottom-right corner (dark mode only for now) + if (!IsZoomed(hDlg)) + { + const int gripWidth = GetSystemMetrics(SM_CXHSCROLL); + const int gripHeight = GetSystemMetrics(SM_CYVSCROLL); + RECT rcGrip = { + rc.right - gripWidth, + rc.bottom - gripHeight, + rc.right, + rc.bottom + }; + + HTHEME hTheme = OpenThemeData(hDlg, L"STATUS"); + if (hTheme) + { + DrawThemeBackground(hTheme, hdc, SP_GRIPPER, 0, &rcGrip, nullptr); + CloseThemeData(hTheme); + } + else + { + DrawFrameControl(hdc, &rcGrip, DFC_SCROLL, DFCS_SCROLLSIZEGRIP); + } + } + + return TRUE; + } + break; + + case WM_GETMINMAXINFO: + { + // Set minimum dialog size to prevent controls from overlapping + MINMAXINFO* mmi = reinterpret_cast(lParam); + // Use MapDialogRect to convert dialog units to pixels + // Minimum size: 440x300 dialog units (smaller than original 521x380) + RECT rcMin = { 0, 0, 440, 300 }; + MapDialogRect(hDlg, &rcMin); + // Add frame/border size + RECT rcFrame = { 0, 0, 0, 0 }; + AdjustWindowRectEx(&rcFrame, GetWindowLong(hDlg, GWL_STYLE), FALSE, GetWindowLong(hDlg, GWL_EXSTYLE)); + const int frameWidth = (rcFrame.right - rcFrame.left); + const int frameHeight = (rcFrame.bottom - rcFrame.top); + mmi->ptMinTrackSize.x = rcMin.right + frameWidth; + mmi->ptMinTrackSize.y = rcMin.bottom + frameHeight; + return 0; + } + + case WM_SIZE: + { + if (wParam == SIZE_MINIMIZED) + { + return 0; + } + + pData = reinterpret_cast(GetWindowLongPtr(hDlg, DWLP_USER)); + if (!pData) + { + return 0; + } + + const int clientWidth = LOWORD(lParam); + const int clientHeight = HIWORD(lParam); + + // Use MapDialogRect to convert dialog units to pixels properly + // This accounts for font metrics and DPI + auto DluToPixels = [hDlg](int dluX, int dluY, int* pxX, int* pxY) { + RECT rc = { 0, 0, dluX, dluY }; + MapDialogRect(hDlg, &rc); + if (pxX) *pxX = rc.right; + if (pxY) *pxY = rc.bottom; + }; + + // Convert dialog unit values to pixels + int marginLeft, marginRight, marginTop; + DluToPixels(12, 12, &marginLeft, &marginTop); + DluToPixels(11, 0, &marginRight, nullptr); + + // Suppress redraw on the entire dialog during layout to prevent tearing + SendMessage(hDlg, WM_SETREDRAW, FALSE, 0); + + // Fixed heights from RC file (in dialog units) converted to pixels + int labelHeight, timelineHeight, buttonRowHeight, okCancelHeight, bottomMargin; + int spacing4, spacing2, spacing8; + DluToPixels(0, 10, nullptr, &labelHeight); // Label height: 10 DLU (for 8pt font) + DluToPixels(0, 50, nullptr, &timelineHeight); // Timeline height: 50 DLU + DluToPixels(0, 32, nullptr, &buttonRowHeight); // Play button height: 32 DLU + DluToPixels(0, 14, nullptr, &okCancelHeight); // OK/Cancel height: 14 DLU + DluToPixels(0, 8, nullptr, &bottomMargin); // Bottom margin + DluToPixels(0, 4, nullptr, &spacing4); // 4 DLU spacing + DluToPixels(0, 2, nullptr, &spacing2); // 2 DLU spacing + DluToPixels(0, 8, nullptr, &spacing8); // 8 DLU spacing + + // Calculate vertical positions from bottom up + const int okCancelY = clientHeight - bottomMargin - okCancelHeight; + const int buttonRowY = okCancelY - spacing4 - buttonRowHeight; + const int timelineY = buttonRowY - spacing4 - timelineHeight; + const int labelY = timelineY - spacing2 - labelHeight; + + // Preview fills from top to above labels + const int previewHeight = labelY - spacing8 - marginTop; + const int previewWidth = clientWidth - marginLeft - marginRight; + const int timelineWidth = previewWidth; + + // Resize preview + HWND hPreview = GetDlgItem(hDlg, IDC_TRIM_PREVIEW); + if (hPreview) + { + SetWindowPos(hPreview, nullptr, marginLeft, marginTop, previewWidth, previewHeight, + SWP_NOZORDER | SWP_NOACTIVATE); + } + + // Position duration label (left-aligned) + HWND hDuration = GetDlgItem(hDlg, IDC_TRIM_DURATION_LABEL); + if (hDuration) + { + int labelWidth; + DluToPixels(160, 0, &labelWidth, nullptr); + SetWindowPos(hDuration, nullptr, marginLeft, labelY, labelWidth, labelHeight, + SWP_NOZORDER | SWP_NOACTIVATE); + } + + // Position time label (centered) + HWND hPosition = GetDlgItem(hDlg, IDC_TRIM_POSITION_LABEL); + if (hPosition) + { + int posLabelWidth; + DluToPixels(200, 0, &posLabelWidth, nullptr); + const int posLabelX = (clientWidth - posLabelWidth) / 2; + SetWindowPos(hPosition, nullptr, posLabelX, labelY, posLabelWidth, labelHeight, + SWP_NOZORDER | SWP_NOACTIVATE); + } + + // Resize timeline + HWND hTimeline = GetDlgItem(hDlg, IDC_TRIM_TIMELINE); + if (hTimeline) + { + SetWindowPos(hTimeline, nullptr, marginLeft, timelineY, timelineWidth, timelineHeight, + SWP_NOZORDER | SWP_NOACTIVATE); + } + + // Position playback buttons (centered horizontally) + // Button sizes: play=44x32, small=30x26 (in dialog units) + int playButtonWidth, playButtonHeight, smallButtonWidth, smallButtonHeight, buttonSpacing; + DluToPixels(44, 32, &playButtonWidth, &playButtonHeight); + DluToPixels(30, 26, &smallButtonWidth, &smallButtonHeight); + DluToPixels(2, 0, &buttonSpacing, nullptr); + + // Count actual buttons present to calculate total width + HWND hSkipStart = GetDlgItem(hDlg, IDC_TRIM_SKIP_START); + HWND hRewind = GetDlgItem(hDlg, IDC_TRIM_REWIND); + HWND hPlayPause = GetDlgItem(hDlg, IDC_TRIM_PLAY_PAUSE); + HWND hForward = GetDlgItem(hDlg, IDC_TRIM_FORWARD); + HWND hSkipEnd = GetDlgItem(hDlg, IDC_TRIM_SKIP_END); + + int numSmallButtons = 0; + int numPlayButtons = 0; + if (hSkipStart) numSmallButtons++; + if (hRewind) numSmallButtons++; + if (hPlayPause) numPlayButtons++; + if (hForward) numSmallButtons++; + if (hSkipEnd) numSmallButtons++; + + const int numButtons = numSmallButtons + numPlayButtons; + const int totalButtonWidth = smallButtonWidth * numSmallButtons + playButtonWidth * numPlayButtons + + buttonSpacing * (numButtons > 0 ? numButtons - 1 : 0); + int buttonX = (clientWidth - totalButtonWidth) / 2; + + if (hSkipStart) + { + const int yOffset = (buttonRowHeight - smallButtonHeight) / 2; + SetWindowPos(hSkipStart, nullptr, buttonX, buttonRowY + yOffset, smallButtonWidth, smallButtonHeight, + SWP_NOZORDER | SWP_NOACTIVATE); + buttonX += smallButtonWidth + buttonSpacing; + } + + if (hRewind) + { + const int yOffset = (buttonRowHeight - smallButtonHeight) / 2; + SetWindowPos(hRewind, nullptr, buttonX, buttonRowY + yOffset, smallButtonWidth, smallButtonHeight, + SWP_NOZORDER | SWP_NOACTIVATE); + buttonX += smallButtonWidth + buttonSpacing; + } + + if (hPlayPause) + { + SetWindowPos(hPlayPause, nullptr, buttonX, buttonRowY, playButtonWidth, playButtonHeight, + SWP_NOZORDER | SWP_NOACTIVATE); + buttonX += playButtonWidth + buttonSpacing; + } + + if (hForward) + { + const int yOffset = (buttonRowHeight - smallButtonHeight) / 2; + SetWindowPos(hForward, nullptr, buttonX, buttonRowY + yOffset, smallButtonWidth, smallButtonHeight, + SWP_NOZORDER | SWP_NOACTIVATE); + buttonX += smallButtonWidth + buttonSpacing; + } + + if (hSkipEnd) + { + const int yOffset = (buttonRowHeight - smallButtonHeight) / 2; + SetWindowPos(hSkipEnd, nullptr, buttonX, buttonRowY + yOffset, smallButtonWidth, smallButtonHeight, + SWP_NOZORDER | SWP_NOACTIVATE); + buttonX += smallButtonWidth + buttonSpacing; + } + + // Position volume icon and slider (to the right of playback buttons) + int volumeIconWidth, volumeIconHeight, volumeSliderWidth, volumeSliderHeight, volumeSpacing; + DluToPixels(14, 12, &volumeIconWidth, &volumeIconHeight); + DluToPixels(70, 14, &volumeSliderWidth, &volumeSliderHeight); + DluToPixels(8, 0, &volumeSpacing, nullptr); + + HWND hVolumeIcon = GetDlgItem(hDlg, IDC_TRIM_VOLUME_ICON); + HWND hVolumeSlider = GetDlgItem(hDlg, IDC_TRIM_VOLUME); + + if (hVolumeIcon) + { + const int iconX = buttonX + volumeSpacing; + const int iconY = buttonRowY + (buttonRowHeight - volumeIconHeight) / 2; + SetWindowPos(hVolumeIcon, nullptr, iconX, iconY, volumeIconWidth, volumeIconHeight, + SWP_NOZORDER | SWP_NOACTIVATE); + } + + if (hVolumeSlider) + { + const int sliderX = buttonX + volumeSpacing + volumeIconWidth + 4; + const int sliderY = buttonRowY + (buttonRowHeight - volumeSliderHeight) / 2; + SetWindowPos(hVolumeSlider, nullptr, sliderX, sliderY, volumeSliderWidth, volumeSliderHeight, + SWP_NOZORDER | SWP_NOACTIVATE); + } + + // Position OK/Cancel buttons (right-aligned) + int okCancelWidth, okCancelSpacingH; + DluToPixels(50, 0, &okCancelWidth, nullptr); + DluToPixels(4, 0, &okCancelSpacingH, nullptr); + + HWND hCancel = GetDlgItem(hDlg, IDCANCEL); + if (hCancel) + { + const int cancelX = clientWidth - marginRight - okCancelWidth; + SetWindowPos(hCancel, nullptr, cancelX, okCancelY, okCancelWidth, okCancelHeight, + SWP_NOZORDER | SWP_NOACTIVATE); + } + + HWND hOK = GetDlgItem(hDlg, IDOK); + if (hOK) + { + const int okX = clientWidth - marginRight - okCancelWidth - okCancelSpacingH - okCancelWidth; + SetWindowPos(hOK, nullptr, okX, okCancelY, okCancelWidth, okCancelHeight, + SWP_NOZORDER | SWP_NOACTIVATE); + } + + // Re-enable redraw and repaint the entire dialog + SendMessage(hDlg, WM_SETREDRAW, TRUE, 0); + // Use RDW_ERASE for the dialog, but invalidate timeline separately without erase to prevent flicker + HWND hTimelineCtrl = GetDlgItem(hDlg, IDC_TRIM_TIMELINE); + RedrawWindow(hDlg, nullptr, nullptr, RDW_ERASE | RDW_FRAME | RDW_INVALIDATE | RDW_ALLCHILDREN); + if (hTimelineCtrl) + { + // Redraw timeline without erase - double buffering handles the background + RedrawWindow(hTimelineCtrl, nullptr, nullptr, RDW_INVALIDATE | RDW_UPDATENOW); + } + return 0; + } + + case WMU_PREVIEW_READY: + { + // Video preview loaded - refresh preview area + pData = reinterpret_cast(GetWindowLongPtr(hDlg, DWLP_USER)); + if (pData) + { + KillTimer(hDlg, kPlaybackTimerId); + InvalidateRect(GetDlgItem(hDlg, IDC_TRIM_PREVIEW), nullptr, FALSE); + } + return TRUE; + } + + case WMU_PREVIEW_SCHEDULED: + { + pData = reinterpret_cast(GetWindowLongPtr(hDlg, DWLP_USER)); + if (pData) + { + UpdateVideoPreview(hDlg, pData); + } + return TRUE; + } + + case WMU_DURATION_CHANGED: + { + pData = reinterpret_cast(GetWindowLongPtr(hDlg, DWLP_USER)); + if (pData) + { + // If the user hasn't manually trimmed (selection was at estimated full duration), + // update the selection to the actual full video duration + if (pData->trimEnd.count() >= pData->originalTrimEnd.count()) + { + pData->trimEnd = pData->videoDuration; + pData->originalTrimEnd = pData->videoDuration; + } + // Clamp trimEnd to actual duration if it exceeds + if (pData->trimEnd.count() > pData->videoDuration.count()) + { + pData->trimEnd = pData->videoDuration; + } + + if (pData->currentPosition.count() > pData->trimEnd.count()) + { + pData->currentPosition = pData->trimEnd; + } + UpdateDurationDisplay(hDlg, pData); + UpdatePositionUI(hDlg, pData); + } + return TRUE; + } + + case WMU_PLAYBACK_POSITION: + { + pData = reinterpret_cast(GetWindowLongPtr(hDlg, DWLP_USER)); + if (pData) + { + // Always move the playhead smoothly + UpdatePositionUI(hDlg, pData); + + // Throttle expensive thumbnail generation while playing + const int64_t currentTicks = pData->currentPosition.count(); + const int64_t lastTicks = pData->lastRenderedPreview.load(std::memory_order_relaxed); + if (!pData->loadingPreview.load(std::memory_order_relaxed)) + { + const int64_t delta = (lastTicks < 0) ? kPreviewMinDeltaTicks : std::llabs(currentTicks - lastTicks); + if (delta >= kPreviewMinDeltaTicks) + { + UpdateVideoPreview(hDlg, pData, false); + } + } + } + return TRUE; + } + + case WMU_PLAYBACK_STOP: + { + pData = reinterpret_cast(GetWindowLongPtr(hDlg, DWLP_USER)); + if (!pData) + { + return TRUE; + } + + // Force UI + session back to the left grip (trim start) position. + pData->currentPosition = pData->trimStart; +#if _DEBUG + OutputDebugStringW((L"[Trim] WMU_PLAYBACK_STOP: resetting to trimStart=" + + std::to_wstring(pData->trimStart.count()) + L"\n").c_str()); +#endif + StopPlayback(hDlg, pData, false); + + // Fast path: if we have a cached frame at the trim start position, restore it instantly. + bool usedCachedFrame = false; + if (pData->hCachedStartFrame && + pData->cachedStartFramePosition.count() == pData->trimStart.count()) + { + std::lock_guard lock(pData->previewBitmapMutex); + if (pData->hCachedStartFrame) // Double-check under lock + { + // Swap the cached frame into the preview + if (pData->hPreviewBitmap && pData->previewBitmapOwned) + { + DeleteObject(pData->hPreviewBitmap); + } + pData->hPreviewBitmap = pData->hCachedStartFrame; + pData->previewBitmapOwned = true; + pData->hCachedStartFrame = nullptr; // Transferred ownership + pData->lastRenderedPreview.store(pData->trimStart.count(), std::memory_order_relaxed); + usedCachedFrame = true; + } + } + + if (usedCachedFrame) + { + // Just update UI - we already have the correct frame + UpdatePositionUI(hDlg, pData, true); + InvalidateRect(GetDlgItem(hDlg, IDC_TRIM_PREVIEW), nullptr, FALSE); + } + else + { + // Fall back to regenerating the preview + UpdateVideoPreview(hDlg, pData); + } + return TRUE; + } + + case WM_DRAWITEM: + { + pData = reinterpret_cast(GetWindowLongPtr(hDlg, DWLP_USER)); + if (!pData) break; + + DRAWITEMSTRUCT* pDIS = reinterpret_cast (lParam); + + if (pDIS->CtlID == IDC_TRIM_TIMELINE) + { + // Draw custom timeline + UINT timelineDpi = GetDpiForWindowHelper(pDIS->hwndItem); + DrawTimeline(pDIS->hDC, pDIS->rcItem, pData, timelineDpi); + return TRUE; + } + else if (pDIS->CtlID == IDC_TRIM_PREVIEW) + { + RECT rcFill = pDIS->rcItem; + const int controlWidth = rcFill.right - rcFill.left; + const int controlHeight = rcFill.bottom - rcFill.top; + + std::unique_lock previewLock(pData->previewBitmapMutex); + + // Create memory DC for double buffering to eliminate flicker + HDC hdcMem = CreateCompatibleDC(pDIS->hDC); + HBITMAP hbmMem = CreateCompatibleBitmap(pDIS->hDC, controlWidth, controlHeight); + HBITMAP hbmOld = static_cast(SelectObject(hdcMem, hbmMem)); + + // Draw to memory DC + RECT rcMem = { 0, 0, controlWidth, controlHeight }; + FillRect(hdcMem, &rcMem, static_cast(GetStockObject(BLACK_BRUSH))); + + if (pData->hPreviewBitmap) + { + HDC hdcBitmap = CreateCompatibleDC(hdcMem); + HBITMAP hOldBitmap = static_cast(SelectObject(hdcBitmap, pData->hPreviewBitmap)); + + BITMAP bm{}; + GetObject(pData->hPreviewBitmap, sizeof(bm), &bm); + + int destWidth = 0; + int destHeight = 0; + + if (bm.bmWidth > 0 && bm.bmHeight > 0) + { + const double scaleX = static_cast(controlWidth) / static_cast(bm.bmWidth); + const double scaleY = static_cast(controlHeight) / static_cast(bm.bmHeight); + // Use min to fit entirely within control (letterbox), not max which crops + const double scale = (std::min)(scaleX, scaleY); + + destWidth = (std::max)(1, static_cast(std::lround(static_cast(bm.bmWidth) * scale))); + destHeight = (std::max)(1, static_cast(std::lround(static_cast(bm.bmHeight) * scale))); + } + else + { + destWidth = controlWidth; + destHeight = controlHeight; + } + + const int offsetX = (controlWidth - destWidth) / 2; + const int offsetY = (controlHeight - destHeight) / 2; + + SetStretchBltMode(hdcMem, HALFTONE); + SetBrushOrgEx(hdcMem, 0, 0, nullptr); + StretchBlt(hdcMem, + offsetX, + offsetY, + destWidth, + destHeight, + hdcBitmap, + 0, + 0, + bm.bmWidth, + bm.bmHeight, + SRCCOPY); + + SelectObject(hdcBitmap, hOldBitmap); + DeleteDC(hdcBitmap); + } + else + { + SetTextColor(hdcMem, RGB(200, 200, 200)); + SetBkMode(hdcMem, TRANSPARENT); + DrawText(hdcMem, L"Preview not available", -1, &rcMem, DT_CENTER | DT_VCENTER | DT_SINGLELINE); + } + + // Copy the buffered image to the screen + BitBlt(pDIS->hDC, rcFill.left, rcFill.top, controlWidth, controlHeight, hdcMem, 0, 0, SRCCOPY); + + // Clean up + SelectObject(hdcMem, hbmOld); + DeleteObject(hbmMem); + DeleteDC(hdcMem); + + return TRUE; + } + else if (pDIS->CtlID == IDC_TRIM_PLAY_PAUSE || pDIS->CtlID == IDC_TRIM_REWIND || + pDIS->CtlID == IDC_TRIM_FORWARD || pDIS->CtlID == IDC_TRIM_SKIP_START || + pDIS->CtlID == IDC_TRIM_SKIP_END) + { + DrawPlaybackButton(pDIS, pData); + return TRUE; + } + else if (pDIS->CtlID == IDC_TRIM_VOLUME_ICON) + { + // Draw speaker icon for volume control + int width = pDIS->rcItem.right - pDIS->rcItem.left; + int height = pDIS->rcItem.bottom - pDIS->rcItem.top; + float centerX = pDIS->rcItem.left + width / 2.0f; + float centerY = pDIS->rcItem.top + height / 2.0f; + + Gdiplus::Graphics graphics(pDIS->hDC); + graphics.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias); + + // Dark background + Gdiplus::SolidBrush bgBrush(Gdiplus::Color(255, 35, 35, 40)); + graphics.FillRectangle(&bgBrush, pDIS->rcItem.left, pDIS->rcItem.top, width, height); + + // Icon color - brighter on hover + const bool isHover = pData && pData->hoverVolumeIcon; + COLORREF iconColor = isHover ? RGB(255, 255, 255) : RGB(180, 180, 180); + Gdiplus::SolidBrush iconBrush(Gdiplus::Color(255, GetRValue(iconColor), GetGValue(iconColor), GetBValue(iconColor))); + Gdiplus::Pen iconPen(Gdiplus::Color(255, GetRValue(iconColor), GetGValue(iconColor), GetBValue(iconColor)), 1.2f); + + // Scale for icon + float scale = min(width, height) / 16.0f; + + // Draw speaker body (rectangle + triangle) + float speakerLeft = centerX - 4.0f * scale; + float speakerWidth = 3.0f * scale; + float speakerHeight = 5.0f * scale; + graphics.FillRectangle(&iconBrush, speakerLeft, centerY - speakerHeight / 2.0f, speakerWidth, speakerHeight); + + // Speaker cone (triangle) + Gdiplus::PointF cone[3] = { + Gdiplus::PointF(speakerLeft + speakerWidth, centerY - speakerHeight / 2.0f), + Gdiplus::PointF(speakerLeft + speakerWidth + 3.0f * scale, centerY - 4.0f * scale), + Gdiplus::PointF(speakerLeft + speakerWidth + 3.0f * scale, centerY + 4.0f * scale) + }; + Gdiplus::PointF cone2[3] = { + Gdiplus::PointF(speakerLeft + speakerWidth, centerY + speakerHeight / 2.0f), + cone[1], + cone[2] + }; + graphics.FillPolygon(&iconBrush, cone, 3); + graphics.FillPolygon(&iconBrush, cone2, 3); + + // Draw sound waves based on volume + if (pData && pData->volume > 0.0) + { + float waveX = speakerLeft + speakerWidth + 4.0f * scale; + + // First wave (always visible when volume > 0) + graphics.DrawArc(&iconPen, waveX, centerY - 2.5f * scale, 3.0f * scale, 5.0f * scale, -60.0f, 120.0f); + + // Second wave (visible when volume > 33%) + if (pData->volume > 0.33) + { + graphics.DrawArc(&iconPen, waveX + 1.5f * scale, centerY - 4.0f * scale, 4.5f * scale, 8.0f * scale, -60.0f, 120.0f); + } + + // Third wave (visible when volume > 66%) + if (pData->volume > 0.66) + { + graphics.DrawArc(&iconPen, waveX + 3.0f * scale, centerY - 5.5f * scale, 6.0f * scale, 11.0f * scale, -60.0f, 120.0f); + } + } + else if (pData && pData->volume == 0.0) + { + // Draw X for muted + float xOffset = speakerLeft + speakerWidth + 5.0f * scale; + graphics.DrawLine(&iconPen, xOffset, centerY - 2.5f * scale, xOffset + 3.5f * scale, centerY + 2.5f * scale); + graphics.DrawLine(&iconPen, xOffset, centerY + 2.5f * scale, xOffset + 3.5f * scale, centerY - 2.5f * scale); + } + return TRUE; + } + break; + } + + case WM_DPICHANGED: + { + HandleDialogDpiChange( hDlg, wParam, lParam, currentDpi ); + // Invalidate preview and timeline to redraw at new DPI + pData = reinterpret_cast(GetWindowLongPtr(hDlg, DWLP_USER)); + if (pData) + { + InvalidateRect(GetDlgItem(hDlg, IDC_TRIM_PREVIEW), nullptr, TRUE); + InvalidateRect(GetDlgItem(hDlg, IDC_TRIM_TIMELINE), nullptr, TRUE); + } + return TRUE; + } + + case WM_DESTROY: + { + // Save dialog size before closing + RECT rcDlg{}; + if (GetWindowRect(hDlg, &rcDlg)) + { + g_TrimDialogWidth = static_cast(rcDlg.right - rcDlg.left); + g_TrimDialogHeight = static_cast(rcDlg.bottom - rcDlg.top); + reg.WriteRegSettings(RegSettings); + } + + pData = reinterpret_cast(GetWindowLongPtr(hDlg, DWLP_USER)); + if (pData) + { + StopPlayback(hDlg, pData); + + // Ensure MediaPlayer and event handlers are fully released + CleanupMediaPlayer(pData); + + HWND hTimeline = GetDlgItem(hDlg, IDC_TRIM_TIMELINE); + if (hTimeline) + { + RemoveWindowSubclass(hTimeline, TimelineSubclassProc, 1); + } + HWND hPlayPause = GetDlgItem(hDlg, IDC_TRIM_PLAY_PAUSE); + if (hPlayPause) + { + RemoveWindowSubclass(hPlayPause, PlaybackButtonSubclassProc, 2); + } + HWND hRewind = GetDlgItem(hDlg, IDC_TRIM_REWIND); + if (hRewind) + { + RemoveWindowSubclass(hRewind, PlaybackButtonSubclassProc, 3); + } + HWND hForward = GetDlgItem(hDlg, IDC_TRIM_FORWARD); + if (hForward) + { + RemoveWindowSubclass(hForward, PlaybackButtonSubclassProc, 4); + } + HWND hVolumeIcon = GetDlgItem(hDlg, IDC_TRIM_VOLUME_ICON); + if (hVolumeIcon) + { + RemoveWindowSubclass(hVolumeIcon, VolumeIconSubclassProc, 7); + } + } + if (pData && pData->hPreviewBitmap) + { + std::lock_guard lock(pData->previewBitmapMutex); + if (pData->previewBitmapOwned) + { + DeleteObject(pData->hPreviewBitmap); + } + pData->hPreviewBitmap = nullptr; + // Also clean up cached playback start frame + if (pData->hCachedStartFrame) + { + DeleteObject(pData->hCachedStartFrame); + pData->hCachedStartFrame = nullptr; + } + } + if (pData) + { + StopMMTimer(pData); // Stop multimedia timer if running + pData->playbackFile = nullptr; + CleanupGifFrames(pData); + // Clean up time label font + if (pData->hTimeLabelFont) + { + DeleteObject(pData->hTimeLabelFont); + pData->hTimeLabelFont = nullptr; + } + } + hDlgTrimDialog = nullptr; + + ReleaseHighResTimer(); + break; + } + + // Multimedia timer tick - handles MP4 and GIF playback with high precision + case WMU_MM_TIMER_TICK: + { + pData = reinterpret_cast(GetWindowLongPtr(hDlg, DWLP_USER)); + if (!pData) + { + return TRUE; + } + + if (!pData->isPlaying.load(std::memory_order_relaxed)) + { + StopMMTimer(pData); + RefreshPlaybackButtons(hDlg); + return TRUE; + } + + // Handle GIF playback + if (pData->isGif && !pData->gifFrames.empty()) + { + // Allow playing from before trimStart - only clamp to video bounds + const int64_t clampedTicks = std::clamp( + pData->currentPosition.count(), + 0, + pData->videoDuration.count()); + const size_t frameIndex = FindGifFrameIndex(pData->gifFrames, clampedTicks); + const auto& frame = pData->gifFrames[frameIndex]; + + // Check if enough real time has passed to advance to the next frame + auto now = std::chrono::steady_clock::now(); + auto elapsedMs = std::chrono::duration_cast(now - pData->gifFrameStartTime).count(); + auto frameDurationMs = frame.duration.count() / 10'000; // Convert 100-ns ticks to ms + + // Update playhead position smoothly based on elapsed time within current frame + const int64_t frameElapsedTicks = static_cast(elapsedMs) * 10'000; + const int64_t smoothPosition = frame.start.count() + (std::min)(frameElapsedTicks, frame.duration.count()); + // Allow positions before trimStart - only clamp to trimEnd + const int64_t clampedPosition = (std::min)(smoothPosition, pData->trimEnd.count()); + + // Check for end-of-clip BEFORE updating UI to avoid showing the end position + // then immediately jumping back to start + if (clampedPosition >= pData->trimEnd.count()) + { + // Immediately mark as not playing to prevent further position updates + pData->isPlaying.store(false, std::memory_order_release); + PostMessage(hDlg, WMU_PLAYBACK_STOP, 0, 0); + return TRUE; + } + + pData->currentPosition = winrt::TimeSpan{ clampedPosition }; + + // Update playhead + HWND hTimeline = GetDlgItem(hDlg, IDC_TRIM_TIMELINE); + if (hTimeline) + { + const UINT dpi = GetDpiForWindowHelper(hTimeline); + RECT rc; + GetClientRect(hTimeline, &rc); + const int newX = TimelineTimeToClientX(pData, pData->currentPosition, rc.right - rc.left, dpi); + if (newX != pData->lastPlayheadX) + { + InvalidatePlayheadRegion(hTimeline, rc, pData->lastPlayheadX, newX, dpi); + pData->lastPlayheadX = newX; + UpdateWindow(hTimeline); + } + } + + // Show time relative to left grip (trimStart) + { + const auto relativePos = winrt::TimeSpan{ (std::max)(pData->currentPosition.count() - pData->trimStart.count(), int64_t{ 0 }) }; + SetTimeText(hDlg, IDC_TRIM_POSITION_LABEL, relativePos, true); + } + + if (elapsedMs >= frameDurationMs) + { + // Time to advance to next frame + const int64_t nextTicks = frame.start.count() + frame.duration.count(); + + if (nextTicks >= pData->trimEnd.count()) + { + // Immediately mark as not playing to prevent further position updates + pData->isPlaying.store(false, std::memory_order_release); + PostMessage(hDlg, WMU_PLAYBACK_STOP, 0, 0); + } + else + { + pData->currentPosition = winrt::TimeSpan{ nextTicks }; + pData->gifFrameStartTime = now; // Reset timer for new frame + UpdateVideoPreview(hDlg, pData); + } + } + return TRUE; + } + + // Handle MP4 playback + if (pData->mediaPlayer) + { + try + { + auto session = pData->mediaPlayer.PlaybackSession(); + if (!session) + { + StopPlayback(hDlg, pData, false); + UpdateVideoPreview(hDlg, pData); + return TRUE; + } + + // Simply use MediaPlayer position directly + auto position = session.Position(); + const int64_t mediaTicks = position.count(); + + // Suppress the transient 0-position report before the initial seek takes effect. + if (pData->pendingInitialSeek.load(std::memory_order_relaxed) && + pData->pendingInitialSeekTicks.load(std::memory_order_relaxed) > 0 && + mediaTicks == 0) + { + return TRUE; + } + + if (mediaTicks != 0) + { + pData->pendingInitialSeek.store(false, std::memory_order_relaxed); + pData->pendingInitialSeekTicks.store(0, std::memory_order_relaxed); + } + + // Allow playing from before trimStart - only clamp to video bounds and trimEnd + const int64_t clampedTicks = std::clamp( + mediaTicks, + 0, + pData->trimEnd.count()); + + // Check for end-of-clip BEFORE updating UI to avoid showing the end position + // then immediately jumping back to start + if (clampedTicks >= pData->trimEnd.count()) + { + // Immediately mark as not playing to prevent further position updates + pData->isPlaying.store(false, std::memory_order_release); + PostMessage(hDlg, WMU_PLAYBACK_STOP, 0, 0); + } + else + { + pData->currentPosition = winrt::TimeSpan{ clampedTicks }; + + // Invalidate only the old and new playhead regions for efficiency + HWND hTimeline = GetDlgItem(hDlg, IDC_TRIM_TIMELINE); + if (hTimeline) + { + const UINT dpi = GetDpiForWindowHelper(hTimeline); + RECT rc; + GetClientRect(hTimeline, &rc); + const int newX = TimelineTimeToClientX(pData, pData->currentPosition, rc.right - rc.left, dpi); + // Only repaint if position actually changed + if (newX != pData->lastPlayheadX) + { + InvalidatePlayheadRegion(hTimeline, rc, pData->lastPlayheadX, newX, dpi); + pData->lastPlayheadX = newX; + UpdateWindow(hTimeline); + } + } + // Show time relative to left grip (trimStart) + { + const auto relativePos = winrt::TimeSpan{ (std::max)(pData->currentPosition.count() - pData->trimStart.count(), int64_t{ 0 }) }; + SetTimeText(hDlg, IDC_TRIM_POSITION_LABEL, relativePos, true); + } + } + } + catch (...) + { + } + } + return TRUE; + } + + case WM_TIMER: + // WM_TIMER is no longer used for playback; both MP4 and GIF use multimedia timer (WMU_MM_TIMER_TICK) + // This handler is kept for any other timers that might be added in the future + if (wParam == kPlaybackTimerId) + { + // Legacy timer - should not fire anymore, but clean up if it does + KillTimer(hDlg, kPlaybackTimerId); + return TRUE; + } + break; + + case WM_HSCROLL: + { + HWND hVolumeSlider = GetDlgItem(hDlg, IDC_TRIM_VOLUME); + if (reinterpret_cast(lParam) == hVolumeSlider) + { + pData = reinterpret_cast(GetWindowLongPtr(hDlg, DWLP_USER)); + if (pData) + { + int pos = static_cast(SendMessage(hVolumeSlider, TBM_GETPOS, 0, 0)); + pData->volume = pos / 100.0; + + // Persist volume setting + g_TrimDialogVolume = static_cast(pos); + reg.WriteRegSettings(RegSettings); + + if (pData->mediaPlayer) + { + try + { + pData->mediaPlayer.Volume(pData->volume); + pData->mediaPlayer.IsMuted(pData->volume == 0.0); + } + catch (...) + { + } + } + // Invalidate volume icon to update its appearance + InvalidateRect(GetDlgItem(hDlg, IDC_TRIM_VOLUME_ICON), nullptr, FALSE); + } + return TRUE; + } + break; + } + + case WM_COMMAND: + switch (LOWORD(wParam)) + { + case IDC_TRIM_VOLUME_ICON: + { + if (HIWORD(wParam) == STN_CLICKED) + { + pData = reinterpret_cast(GetWindowLongPtr(hDlg, DWLP_USER)); + if (pData) + { + HWND hVolumeSlider = GetDlgItem(hDlg, IDC_TRIM_VOLUME); + + if (pData->volume > 0.0) + { + // Mute: save current volume and set to 0 + pData->previousVolume = pData->volume; + pData->volume = 0.0; + } + else + { + // Unmute: restore previous volume (default to 70% if never set) + pData->volume = (pData->previousVolume > 0.0) ? pData->previousVolume : 0.70; + } + + // Update slider position + if (hVolumeSlider) + { + SendMessage(hVolumeSlider, TBM_SETPOS, TRUE, static_cast(pData->volume * 100)); + // Force full redraw to avoid leftover thumb artifacts + InvalidateRect(hVolumeSlider, nullptr, TRUE); + } + + // Persist volume setting + g_TrimDialogVolume = static_cast(pData->volume * 100); + reg.WriteRegSettings(RegSettings); + + // Apply to media player + if (pData->mediaPlayer) + { + try + { + pData->mediaPlayer.Volume(pData->volume); + pData->mediaPlayer.IsMuted(pData->volume == 0.0); + } + catch (...) + { + } + } + + // Update icon appearance + InvalidateRect(GetDlgItem(hDlg, IDC_TRIM_VOLUME_ICON), nullptr, FALSE); + } + return TRUE; + } + break; + } + + case IDC_TRIM_REWIND: + case IDC_TRIM_PLAY_PAUSE: + case IDC_TRIM_FORWARD: + case IDC_TRIM_SKIP_START: + case IDC_TRIM_SKIP_END: + { + if (HIWORD(wParam) == BN_CLICKED) + { + pData = reinterpret_cast(GetWindowLongPtr(hDlg, DWLP_USER)); + HandlePlaybackCommand(static_cast(LOWORD(wParam)), pData); + return TRUE; + } + break; + } + + case IDOK: + pData = reinterpret_cast(GetWindowLongPtr(hDlg, DWLP_USER)); + StopPlayback(hDlg, pData); + // Trim times are already set by mouse dragging + EndDialog(hDlg, IDOK); + return TRUE; + + case IDCANCEL: + pData = reinterpret_cast(GetWindowLongPtr(hDlg, DWLP_USER)); + StopPlayback(hDlg, pData); + EndDialog(hDlg, IDCANCEL); + return TRUE; + } + break; + } + + return FALSE; +} + +//---------------------------------------------------------------------------- +// +// VideoRecordingSession::TrimVideoAsync +// +// Performs the actual video trimming operation +// +//---------------------------------------------------------------------------- +winrt::IAsyncOperation VideoRecordingSession::TrimVideoAsync( + const std::wstring& sourceVideoPath, + winrt::TimeSpan trimTimeStart, + winrt::TimeSpan trimTimeEnd) +{ + try + { + // Load the source video file + auto sourceFile = co_await winrt::StorageFile::GetFileFromPathAsync(sourceVideoPath); + + // Create a media composition + winrt::MediaComposition composition; + auto clip = co_await winrt::MediaClip::CreateFromFileAsync(sourceFile); + + // Set the trim times + clip.TrimTimeFromStart(trimTimeStart); + clip.TrimTimeFromEnd(clip.OriginalDuration() - trimTimeEnd); + + // Add the trimmed clip to the composition + composition.Clips().Append(clip); + + // Create output file in temp folder + auto tempFolder = co_await winrt::StorageFolder::GetFolderFromPathAsync( + std::filesystem::temp_directory_path().wstring()); + auto zoomitFolder = co_await tempFolder.CreateFolderAsync( + L"ZoomIt", winrt::CreationCollisionOption::OpenIfExists); + + // Generate unique filename + std::wstring filename = L"zoomit_trimmed_" + + std::to_wstring(GetTickCount64()) + L".mp4"; + auto outputFile = co_await zoomitFolder.CreateFileAsync( + filename, winrt::CreationCollisionOption::ReplaceExisting); + + // Render the composition to the output file with fast trimming (no re-encode) + auto renderResult = co_await composition.RenderToFileAsync( + outputFile, winrt::MediaTrimmingPreference::Fast); + + if (renderResult == winrt::TranscodeFailureReason::None) + { + co_return winrt::hstring(outputFile.Path()); + } + else + { + co_return winrt::hstring(); + } + } + catch (...) + { + co_return winrt::hstring(); + } +} + +winrt::IAsyncOperation VideoRecordingSession::TrimGifAsync( + const std::wstring& sourceGifPath, + winrt::TimeSpan trimTimeStart, + winrt::TimeSpan trimTimeEnd) +{ + co_await winrt::resume_background(); + + try + { + if (trimTimeEnd.count() <= trimTimeStart.count()) + { + co_return winrt::hstring(); + } + + winrt::com_ptr factory; + winrt::check_hresult(CoCreateInstance(CLSID_WICImagingFactory, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(factory.put()))); + + auto sourceFile = co_await winrt::StorageFile::GetFileFromPathAsync(sourceGifPath); + auto sourceStream = co_await sourceFile.OpenAsync(winrt::FileAccessMode::Read); + + winrt::com_ptr sourceIStream; + winrt::check_hresult(CreateStreamOverRandomAccessStream(winrt::get_unknown(sourceStream), IID_PPV_ARGS(sourceIStream.put()))); + + winrt::com_ptr decoder; + winrt::check_hresult(factory->CreateDecoderFromStream(sourceIStream.get(), nullptr, WICDecodeMetadataCacheOnLoad, decoder.put())); + + UINT frameCount = 0; + winrt::check_hresult(decoder->GetFrameCount(&frameCount)); + if (frameCount == 0) + { + co_return winrt::hstring(); + } + + // Prepare output file + auto tempFolder = co_await winrt::StorageFolder::GetFolderFromPathAsync(std::filesystem::temp_directory_path().wstring()); + auto zoomitFolder = co_await tempFolder.CreateFolderAsync(L"ZoomIt", winrt::CreationCollisionOption::OpenIfExists); + std::wstring filename = L"zoomit_trimmed_" + std::to_wstring(GetTickCount64()) + L".gif"; + auto outputFile = co_await zoomitFolder.CreateFileAsync(filename, winrt::CreationCollisionOption::ReplaceExisting); + auto outputStream = co_await outputFile.OpenAsync(winrt::FileAccessMode::ReadWrite); + + winrt::com_ptr outputIStream; + winrt::check_hresult(CreateStreamOverRandomAccessStream(winrt::get_unknown(outputStream), IID_PPV_ARGS(outputIStream.put()))); + + winrt::com_ptr encoder; + winrt::check_hresult(factory->CreateEncoder(GUID_ContainerFormatGif, nullptr, encoder.put())); + winrt::check_hresult(encoder->Initialize(outputIStream.get(), WICBitmapEncoderNoCache)); + + // Try to set looping metadata + try + { + winrt::com_ptr encoderMetadataWriter; + if (SUCCEEDED(encoder->GetMetadataQueryWriter(encoderMetadataWriter.put())) && encoderMetadataWriter) + { + PROPVARIANT prop{}; + PropVariantInit(&prop); + prop.vt = VT_UI1 | VT_VECTOR; + prop.caub.cElems = 11; + prop.caub.pElems = static_cast(CoTaskMemAlloc(11)); + if (prop.caub.pElems) + { + memcpy(prop.caub.pElems, "NETSCAPE2.0", 11); + encoderMetadataWriter->SetMetadataByName(L"/appext/application", &prop); + } + PropVariantClear(&prop); + + PropVariantInit(&prop); + prop.vt = VT_UI1 | VT_VECTOR; + prop.caub.cElems = 5; + prop.caub.pElems = static_cast(CoTaskMemAlloc(5)); + if (prop.caub.pElems) + { + prop.caub.pElems[0] = 3; + prop.caub.pElems[1] = 1; + prop.caub.pElems[2] = 0; + prop.caub.pElems[3] = 0; + prop.caub.pElems[4] = 0; + encoderMetadataWriter->SetMetadataByName(L"/appext/data", &prop); + } + PropVariantClear(&prop); + } + } + catch (...) + { + // Loop metadata is optional; continue without failing + } + + int64_t cumulativeTicks = 0; + bool wroteFrame = false; + + for (UINT i = 0; i < frameCount; ++i) + { + winrt::com_ptr frame; + if (FAILED(decoder->GetFrame(i, frame.put()))) + { + continue; + } + + UINT delayCs = kGifDefaultDelayCs; + try + { + winrt::com_ptr metadata; + if (SUCCEEDED(frame->GetMetadataQueryReader(metadata.put())) && metadata) + { + PROPVARIANT prop{}; + PropVariantInit(&prop); + if (SUCCEEDED(metadata->GetMetadataByName(L"/grctlext/Delay", &prop))) + { + if (prop.vt == VT_UI2) + { + delayCs = prop.uiVal; + } + else if (prop.vt == VT_UI1) + { + delayCs = prop.bVal; + } + } + PropVariantClear(&prop); + } + } + catch (...) + { + } + + if (delayCs == 0) + { + delayCs = kGifDefaultDelayCs; + } + + const int64_t frameStart = cumulativeTicks; + const int64_t frameEnd = frameStart + static_cast(delayCs) * 100'000; + cumulativeTicks = frameEnd; + + if (frameEnd <= trimTimeStart.count() || frameStart >= trimTimeEnd.count()) + { + continue; + } + + const int64_t visibleStart = (std::max)(frameStart, trimTimeStart.count()); + const int64_t visibleEnd = (std::min)(frameEnd, trimTimeEnd.count()); + const int64_t visibleTicks = visibleEnd - visibleStart; + if (visibleTicks <= 0) + { + continue; + } + + UINT width = 0; + UINT height = 0; + frame->GetSize(&width, &height); + + winrt::com_ptr frameEncode; + winrt::com_ptr propertyBag; + winrt::check_hresult(encoder->CreateNewFrame(frameEncode.put(), propertyBag.put())); + winrt::check_hresult(frameEncode->Initialize(propertyBag.get())); + winrt::check_hresult(frameEncode->SetSize(width, height)); + + WICPixelFormatGUID pixelFormat = GUID_WICPixelFormat8bppIndexed; + winrt::check_hresult(frameEncode->SetPixelFormat(&pixelFormat)); + + winrt::com_ptr converter; + winrt::check_hresult(factory->CreateFormatConverter(converter.put())); + winrt::check_hresult(converter->Initialize(frame.get(), GUID_WICPixelFormat32bppBGRA, WICBitmapDitherTypeNone, nullptr, 0.0, WICBitmapPaletteTypeCustom)); + + winrt::check_hresult(frameEncode->WriteSource(converter.get(), nullptr)); + + try + { + winrt::com_ptr frameMetadataWriter; + if (SUCCEEDED(frameEncode->GetMetadataQueryWriter(frameMetadataWriter.put())) && frameMetadataWriter) + { + PROPVARIANT prop{}; + PropVariantInit(&prop); + prop.vt = VT_UI2; + // Convert ticks (100ns) to centiseconds with rounding and minimum 1 + const int64_t roundedCs = (visibleTicks + 50'000) / 100'000; + prop.uiVal = static_cast((std::max)(1, roundedCs)); + frameMetadataWriter->SetMetadataByName(L"/grctlext/Delay", &prop); + PropVariantClear(&prop); + + PropVariantInit(&prop); + prop.vt = VT_UI1; + prop.bVal = 2; // restore to background + frameMetadataWriter->SetMetadataByName(L"/grctlext/Disposal", &prop); + PropVariantClear(&prop); + } + } + catch (...) + { + } + + winrt::check_hresult(frameEncode->Commit()); + wroteFrame = true; + } + + winrt::check_hresult(encoder->Commit()); + + if (!wroteFrame) + { + co_return winrt::hstring(); + } + + co_return winrt::hstring(outputFile.Path()); + } + catch (...) + { + co_return winrt::hstring(); + } +} diff --git a/src/modules/ZoomIt/ZoomIt/VideoRecordingSession.h b/src/modules/ZoomIt/ZoomIt/VideoRecordingSession.h index 960ac36444..c199e9d4b9 100644 --- a/src/modules/ZoomIt/ZoomIt/VideoRecordingSession.h +++ b/src/modules/ZoomIt/ZoomIt/VideoRecordingSession.h @@ -11,6 +11,12 @@ #include "CaptureFrameWait.h" #include "AudioSampleGenerator.h" #include +#include +#include +#include +#include +#include +#include class VideoRecordingSession : public std::enable_shared_from_this { @@ -21,6 +27,7 @@ public: RECT const& cropRect, uint32_t frameRate, bool captureAudio, + bool captureSystemAudio, winrt::Streams::IRandomAccessStream const& stream); ~VideoRecordingSession(); @@ -28,6 +35,151 @@ public: void EnableCursorCapture(bool enable = true) { m_frameWait->EnableCursorCapture(enable); } void Close(); + bool HasCapturedVideoFrames() const { return m_hasVideoSample.load(); } + + // Trim and save functionality + static std::wstring ShowSaveDialogWithTrim( + HWND hWnd, + const std::wstring& suggestedFileName, + const std::wstring& originalVideoPath, + std::wstring& trimmedVideoPath); + + struct TrimDialogData + { + struct GifFrame + { + HBITMAP hBitmap{ nullptr }; + winrt::Windows::Foundation::TimeSpan start{ 0 }; + winrt::Windows::Foundation::TimeSpan duration{ 0 }; + UINT width{ 0 }; + UINT height{ 0 }; + }; + + std::wstring videoPath; + winrt::Windows::Foundation::TimeSpan videoDuration{ 0 }; + winrt::Windows::Foundation::TimeSpan trimStart{ 0 }; + winrt::Windows::Foundation::TimeSpan trimEnd{ 0 }; + winrt::Windows::Foundation::TimeSpan originalTrimStart{ 0 }; // Initial value to detect if trim needed + winrt::Windows::Foundation::TimeSpan originalTrimEnd{ 0 }; // Initial value to detect if trim needed + winrt::Windows::Foundation::TimeSpan currentPosition{ 0 }; + // Playback loop anchor. This is set when the user explicitly positions the playhead + // (e.g., dragging or using the jog buttons). Pausing/resuming should not change it. + winrt::Windows::Foundation::TimeSpan playbackStartPosition{ 0 }; + bool playbackStartPositionValid{ false }; + + // Cached preview frame at playback start position for instant restore when playback stops. + HBITMAP hCachedStartFrame{ nullptr }; + winrt::Windows::Foundation::TimeSpan cachedStartFramePosition{ -1 }; + + // When starting playback at a non-zero position, MediaPlayer may briefly report Position==0 + // before the initial seek is applied. Use this to suppress a one-frame UI jump to 0. + std::atomic pendingInitialSeek{ false }; + std::atomic pendingInitialSeekTicks{ 0 }; + winrt::Windows::Media::Editing::MediaComposition composition{ nullptr }; + winrt::Windows::Media::Playback::MediaPlayer mediaPlayer{ nullptr }; + winrt::Windows::Storage::StorageFile playbackFile{ nullptr }; + HBITMAP hPreviewBitmap{ nullptr }; + HWND hDialog{ nullptr }; + std::atomic loadingPreview{ false }; + std::atomic latestPreviewRequest{ 0 }; + std::atomic lastRenderedPreview{ -1 }; + std::atomic isPlaying{ false }; + // Monotonic serial used to cancel in-flight StartPlaybackAsync work when the user + // immediately pauses after starting playback. + std::atomic playbackCommandSerial{ 0 }; + std::atomic frameCopyInProgress{ false }; + std::atomic smoothActive{ false }; + std::atomic smoothBaseTicks{ 0 }; + std::atomic smoothLastSyncMicroseconds{ 0 }; + std::atomic smoothHasNonZeroSample{ false }; + std::mutex previewBitmapMutex; + winrt::event_token frameAvailableToken{}; + winrt::event_token positionChangedToken{}; + winrt::event_token stateChangedToken{}; + winrt::com_ptr previewD3DDevice; + winrt::com_ptr previewD3DContext; + winrt::com_ptr previewFrameTexture; + winrt::com_ptr previewFrameStaging; + bool hoverPlay{ false }; + bool hoverRewind{ false }; + bool hoverForward{ false }; + bool hoverSkipStart{ false }; + bool hoverSkipEnd{ false }; + bool hoverVolumeIcon{ false }; + double volume{ 0.70 }; // Volume level 0.0 to 1.0, initialized from g_TrimDialogVolume in dialog init + double previousVolume{ 0.70 }; // Volume before muting, for unmute restoration + winrt::Windows::Foundation::TimeSpan previewOverride{ 0 }; + winrt::Windows::Foundation::TimeSpan positionBeforeOverride{ 0 }; + bool previewOverrideActive{ false }; + bool restorePreviewOnRelease{ false }; + bool playheadPushed{ false }; + int dialogX{ 0 }; + int dialogY{ 0 }; + bool isGif{ false }; + bool previewBitmapOwned{ true }; + std::vector gifFrames; + bool gifFramesLoaded{ false }; + size_t gifLastFrameIndex{ 0 }; + std::chrono::steady_clock::time_point gifFrameStartTime{}; // When the current GIF frame started displaying + + // Font for time labels + HFONT hTimeLabelFont{ nullptr }; + + // Mouse tracking for timeline + enum DragMode { None, TrimStart, Position, TrimEnd }; + DragMode dragMode{ None }; + bool isDragging{ false }; + int lastPlayheadX{ -1 }; // Track last playhead pixel position for efficient invalidation + MMRESULT mmTimerId{ 0 }; // Multimedia timer for smooth MP4 playback + + // Helper to convert time to pixel position + int TimeToPixel(winrt::Windows::Foundation::TimeSpan time, int timelineWidth) const + { + if (timelineWidth <= 0 || videoDuration.count() <= 0) + { + return 0; + } + double ratio = static_cast(time.count()) / static_cast(videoDuration.count()); + ratio = std::clamp(ratio, 0.0, 1.0); + return static_cast(ratio * timelineWidth); + } + + // Helper to convert pixel to time + winrt::Windows::Foundation::TimeSpan PixelToTime(int pixel, int timelineWidth) const + { + if (timelineWidth <= 0 || videoDuration.count() <= 0) + { + return winrt::Windows::Foundation::TimeSpan{ 0 }; + } + int clampedPixel = std::clamp(pixel, 0, timelineWidth); + double ratio = static_cast(clampedPixel) / static_cast(timelineWidth); + return winrt::Windows::Foundation::TimeSpan{ static_cast(ratio * videoDuration.count()) }; + } + }; + + static INT_PTR ShowTrimDialog( + HWND hParent, + const std::wstring& videoPath, + winrt::Windows::Foundation::TimeSpan& trimStart, + winrt::Windows::Foundation::TimeSpan& trimEnd); + +private: + static INT_PTR CALLBACK TrimDialogProc(HWND hDlg, UINT message, WPARAM wParam, LPARAM lParam); + + static winrt::Windows::Foundation::IAsyncOperation TrimVideoAsync( + const std::wstring& sourceVideoPath, + winrt::Windows::Foundation::TimeSpan trimTimeStart, + winrt::Windows::Foundation::TimeSpan trimTimeEnd); + static winrt::Windows::Foundation::IAsyncOperation TrimGifAsync( + const std::wstring& sourceGifPath, + winrt::Windows::Foundation::TimeSpan trimTimeStart, + winrt::Windows::Foundation::TimeSpan trimTimeEnd); + static INT_PTR ShowTrimDialogInternal( + HWND hParent, + const std::wstring& videoPath, + winrt::Windows::Foundation::TimeSpan& trimStart, + winrt::Windows::Foundation::TimeSpan& trimEnd); + private: VideoRecordingSession( winrt::Direct3D11::IDirect3DDevice const& device, @@ -35,6 +187,7 @@ private: RECT const cropRect, uint32_t frameRate, bool captureAudio, + bool captureSystemAudio, winrt::Streams::IRandomAccessStream const& stream); void CloseInternal(); @@ -68,4 +221,7 @@ private: std::atomic m_isRecording = false; std::atomic m_closed = false; + + // Set once the MediaStreamSource successfully returns at least one video sample. + std::atomic m_hasVideoSample = false; }; \ No newline at end of file diff --git a/src/modules/ZoomIt/ZoomIt/ZoomIt.rc b/src/modules/ZoomIt/ZoomIt/ZoomIt.rc index 5f5e9d16cf..d3c5210744 100644 --- a/src/modules/ZoomIt/ZoomIt/ZoomIt.rc +++ b/src/modules/ZoomIt/ZoomIt/ZoomIt.rc @@ -32,18 +32,18 @@ LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US // TEXTINCLUDE // -1 TEXTINCLUDE +1 TEXTINCLUDE BEGIN "resource.h\0" END -2 TEXTINCLUDE +2 TEXTINCLUDE BEGIN "#include ""winres.h""\r\n" "\0" END -3 TEXTINCLUDE +3 TEXTINCLUDE BEGIN "#include ""binres.rc""\0" END @@ -113,26 +113,26 @@ END // Dialog // -OPTIONS DIALOGEX 0, 0, 279, 325 +OPTIONS DIALOGEX 0, 0, 299, 325 STYLE DS_SETFONT | DS_MODALFRAME | DS_FIXEDSYS | DS_CENTER | WS_POPUP | WS_CLIPSIBLINGS | WS_CAPTION | WS_SYSMENU EXSTYLE WS_EX_CONTROLPARENT CAPTION "ZoomIt - Sysinternals: www.sysinternals.com" FONT 8, "MS Shell Dlg", 0, 0, 0x0 BEGIN - DEFPUSHBUTTON "OK",IDOK,166,306,50,14 - PUSHBUTTON "Cancel",IDCANCEL,223,306,50,14 - LTEXT "ZoomIt v9.21",IDC_VERSION,42,7,73,10 - LTEXT "Copyright © 2006-2025 Mark Russinovich",IDC_COPYRIGHT,42,17,231,8 + DEFPUSHBUTTON "OK",IDOK,186,306,50,14 + PUSHBUTTON "Cancel",IDCANCEL,243,306,50,14 + LTEXT "ZoomIt v10.1",IDC_VERSION,42,7,73,10 + LTEXT "Copyright \251 2006-2026 Mark Russinovich",IDC_COPYRIGHT,42,17,251,8 CONTROL "
Sysinternals - www.sysinternals.com",IDC_LINK, "SysLink",WS_TABSTOP,42,26,150,9 ICON "APPICON",IDC_STATIC,12,9,20,20 CONTROL "Show tray icon",IDC_SHOW_TRAY_ICON,"Button",BS_AUTOCHECKBOX | WS_TABSTOP,13,295,105,10 - CONTROL "",IDC_TAB,"SysTabControl32",TCS_MULTILINE | WS_TABSTOP,8,46,265,245 + CONTROL "",IDC_TAB,"SysTabControl32",TCS_MULTILINE | WS_TABSTOP,8,46,285,247 CONTROL "Run ZoomIt when Windows starts",IDC_AUTOSTART,"Button",BS_AUTOCHECKBOX | WS_TABSTOP,13,309,122,10 END -ADVANCED_BREAK DIALOGEX 0, 0, 209, 219 -STYLE DS_SETFONT | DS_MODALFRAME | DS_FIXEDSYS | DS_CENTER | WS_POPUP | WS_CAPTION | WS_SYSMENU +ADVANCED_BREAK DIALOGEX 0, 0, 209, 225 +STYLE DS_SETFONT | DS_MODALFRAME | DS_CENTER | WS_POPUP | WS_CAPTION | WS_SYSMENU CAPTION "Advanced Break Options" FONT 8, "MS Shell Dlg", 400, 0, 0x1 BEGIN @@ -158,23 +158,22 @@ BEGIN EDITTEXT IDC_BACKGROUND_FILE,62,164,125,12,ES_AUTOHSCROLL | ES_READONLY PUSHBUTTON "&...",IDC_BACKGROUND_BROWSE,188,164,13,11 CONTROL "Scale to screen:",IDC_CHECK_BACKGROUND_STRETCH,"Button",BS_AUTOCHECKBOX | WS_TABSTOP,58,180,67,10,WS_EX_RIGHT - DEFPUSHBUTTON "OK",IDOK,97,201,50,14 - PUSHBUTTON "Cancel",IDCANCEL,150,201,50,14 + DEFPUSHBUTTON "OK",IDOK,97,199,50,14 + PUSHBUTTON "Cancel",IDCANCEL,150,199,50,14 LTEXT "Alarm Sound File:",IDC_STATIC_SOUND_FILE,61,26,56,8 LTEXT "Timer Opacity:",IDC_STATIC,8,59,48,8 LTEXT "Timer Position:",IDC_STATIC,8,77,48,8 - CONTROL "",IDC_STATIC,"Static",SS_BLACKFRAME | SS_SUNKEN,7,196,193,1,WS_EX_CLIENTEDGE END ZOOM DIALOGEX 0, 0, 260, 170 -STYLE DS_SETFONT | DS_FIXEDSYS | DS_CONTROL | WS_CHILD | WS_CLIPSIBLINGS | WS_SYSMENU +STYLE DS_SETFONT | DS_CONTROL | WS_CHILD | WS_CLIPSIBLINGS | WS_SYSMENU FONT 8, "MS Shell Dlg", 400, 0, 0x1 BEGIN CONTROL "",IDC_HOTKEY,"msctls_hotkey32",WS_BORDER | WS_TABSTOP,59,57,80,12 - LTEXT "After toggling ZoomIt you can zoom in with the mouse wheel or up and down arrow keys. Exit zoom mode with Escape or by pressing the right mouse button.",IDC_STATIC,7,6,246,26 + LTEXT "After toggling ZoomIt you can zoom in with the mouse wheel or up and down arrow keys. Exit zoom mode with Escape or by pressing the right mouse button.",IDC_STATIC,7,6,230,26 LTEXT "Zoom Toggle:",IDC_STATIC,7,59,51,8 CONTROL "",IDC_ZOOM_SLIDER,"msctls_trackbar32",TBS_AUTOTICKS | TBS_BOTH | TBS_NOTICKS | WS_TABSTOP,53,118,150,15,WS_EX_TRANSPARENT - LTEXT "Specify the initial level of magnification when zooming in:",IDC_STATIC,7,105,215,10 + LTEXT "Specify the initial level of magnification when zooming in:",IDC_STATIC,7,105,230,10 LTEXT "1.25",IDC_STATIC,52,136,16,8 LTEXT "1.5",IDC_STATIC,82,136,12,8 LTEXT "1.75",IDC_STATIC,108,136,16,8 @@ -183,52 +182,52 @@ BEGIN LTEXT "4.0",IDC_STATIC,190,136,12,8 CONTROL "Animate zoom in and zoom out:",IDC_ANIMATE_ZOOM,"Button",BS_AUTOCHECKBOX | BS_LEFTTEXT | WS_TABSTOP,7,74,116,10 CONTROL "Smooth zoomed image:",IDC_SMOOTH_IMAGE,"Button",BS_AUTOCHECKBOX | BS_LEFTTEXT | WS_TABSTOP,7,88,116,10 - LTEXT "Copy a zoomed screen with Ctrl+C or save it by typing Ctrl+S. Crop the copy or save region by entering Ctrl+Shift instead of Ctrl.",IDC_STATIC,7,148,246,17 - LTEXT "Copy a zoomed screen with Ctrl+C or save it by typing Ctrl+S. Crop the copy or save region by entering Ctrl+Shift instead of Ctrl.",IDC_STATIC,6,34,246,18 + LTEXT "Copy a zoomed screen with Ctrl+C or save it by typing Ctrl+S. Crop the copy or save region by entering Ctrl+Shift instead of Ctrl.",IDC_STATIC,7,148,230,17 + LTEXT "Copy a zoomed screen with Ctrl+C or save it by typing Ctrl+S. Crop the copy or save region by entering Ctrl+Shift instead of Ctrl.",IDC_STATIC,6,34,230,18 END DRAW DIALOGEX 0, 0, 260, 228 -STYLE DS_SETFONT | DS_FIXEDSYS | DS_CONTROL | WS_CHILD | WS_SYSMENU +STYLE DS_SETFONT | DS_CONTROL | WS_CHILD | WS_SYSMENU FONT 8, "MS Shell Dlg", 400, 0, 0x1 BEGIN - LTEXT "Once zoomed, toggle drawing mode by pressing the left mouse button. Undo with Ctrl+Z and all drawing by pressing E. Center the cursor with the space bar. Exit drawing mode by pressing the right mouse button.",IDC_STATIC,7,7,246,24 + LTEXT "Once zoomed, toggle drawing mode by pressing the left mouse button. Undo with Ctrl+Z and all drawing by pressing E. Center the cursor with the space bar. Exit drawing mode by pressing the right mouse button.",IDC_STATIC,7,7,230,24 LTEXT "Pen Control ",IDC_PEN_CONTROL,7,38,40,8 - LTEXT "Change the pen width by pressing left Ctrl and using the mouse wheel or the up and down arrow keys.",IDC_STATIC,19,48,233,16 + LTEXT "Change the pen width by pressing left Ctrl and using the mouse wheel or the up and down arrow keys.",IDC_STATIC,19,48,218,16 LTEXT "Colors",IDC_COLORS,7,70,21,8 - LTEXT "Change the pen color by pressing R (red), G (green), B (blue),\nO (orange), Y (yellow) or P (pink).",IDC_STATIC,19,80,233,16 + LTEXT "Change the pen color by pressing R (red), G (green), B (blue),\nO (orange), Y (yellow) or P (pink).",IDC_STATIC,19,80,218,16 LTEXT "Highlight and Blur",IDC_HIGHLIGHT_AND_BLUR,7,102,58,8 - LTEXT "Hold Shift while pressing a color key for a translucent highlighter color. Press X for blur or Shift+X for a stronger blur.",IDC_STATIC,19,113,233,16 + LTEXT "Hold Shift while pressing a color key for a translucent highlighter color. Press X for blur or Shift+X for a stronger blur.",IDC_STATIC,19,113,218,16 LTEXT "Shapes",IDC_SHAPES,7,134,23,8 - LTEXT "Draw a line by holding down the Shift key, a rectangle with the Ctrl key, an ellipse with the Tab key and an arrow with Shift+Ctrl.",IDC_STATIC,19,144,233,16 + LTEXT "Draw a line by holding down the Shift key, a rectangle with the Ctrl key, an ellipse with the Tab key and an arrow with Shift+Ctrl.",IDC_STATIC,19,144,218,16 LTEXT "Screen",IDC_SCREEN,7,166,22,8 - LTEXT "Clear the screen for a sketch pad by pressing W (white) or K (black). Copy a zoomed screen with Ctrl+C or save it by typing Ctrl+S. Crop the copy or save region by entering Ctrl+Shift instead of Ctrl.",IDC_STATIC,19,176,233,24 + LTEXT "Clear the screen for a sketch pad by pressing W (white) or K (black). Copy a zoomed screen with Ctrl+C or save it by typing Ctrl+S. Crop the copy or save region by entering Ctrl+Shift instead of Ctrl.",IDC_STATIC,19,176,218,24 CONTROL "",IDC_DRAW_HOTKEY,"msctls_hotkey32",WS_BORDER | WS_TABSTOP,73,207,80,12 LTEXT "Draw w/out Zoom:",IDC_STATIC,7,210,63,11 END TYPE DIALOGEX 0, 0, 260, 104 -STYLE DS_SETFONT | DS_FIXEDSYS | DS_CONTROL | WS_CHILD | WS_SYSMENU +STYLE DS_SETFONT | DS_CONTROL | WS_CHILD | WS_SYSMENU FONT 8, "MS Shell Dlg", 400, 0, 0x1 BEGIN - LTEXT "Once in drawing mode, type 't' to enter typing mode or shift+'t' to enter typing mode with right-aligned input. Exit typing mode by pressing escape or the left mouse button. Use the mouse wheel or up and down arrow keys to change the font size.",IDC_STATIC,7,7,246,32 - LTEXT "The text color is the current drawing color.",IDC_STATIC,7,47,211,9 + LTEXT "Once in drawing mode, type 't' to enter typing mode or shift+'t' to enter typing mode with right-aligned input. Exit typing mode by pressing escape or the left mouse button. Use the mouse wheel or up and down arrow keys to change the font size.",IDC_STATIC,7,7,230,32 + LTEXT "The text color is the current drawing color.",IDC_STATIC,7,47,230,9 PUSHBUTTON "&Font",IDC_FONT,112,69,41,14 - GROUPBOX "Text Font",IDC_TEXT_FONT,8,61,99,28 + GROUPBOX "Sample",IDC_TEXT_FONT,8,61,99,28 END BREAK DIALOGEX 0, 0, 260, 123 -STYLE DS_SETFONT | DS_FIXEDSYS | DS_CONTROL | WS_CHILD | WS_SYSMENU +STYLE DS_SETFONT | DS_CONTROL | WS_CHILD | WS_SYSMENU FONT 8, "MS Shell Dlg", 400, 0, 0x1 BEGIN CONTROL "",IDC_BREAK_HOTKEY,"msctls_hotkey32",WS_BORDER | WS_TABSTOP,52,67,80,12 - EDITTEXT IDC_TIMER,31,86,31,13,ES_RIGHT | ES_AUTOHSCROLL | ES_NUMBER - CONTROL "",IDC_SPIN_TIMER,"msctls_updown32",UDS_SETBUDDYINT | UDS_ALIGNRIGHT | UDS_AUTOBUDDY | UDS_ARROWKEYS | UDS_NOTHOUSANDS,45,86,11,12 - LTEXT "minutes",IDC_STATIC,67,88,25,8 - PUSHBUTTON "&Advanced",IDC_ADVANCED_BREAK,212,102,41,14 - LTEXT "Enter timer mode by using the ZoomIt tray icon's Break menu item. Increase and decrease time with the arrow keys. If you Alt-Tab away from the timer window, reactivate it by left-clicking on the ZoomIt tray icon. Exit timer mode with Escape. ",IDC_STATIC,7,7,246,33 + EDITTEXT IDC_TIMER,52,86,31,13,ES_RIGHT | ES_AUTOHSCROLL | ES_NUMBER + CONTROL "",IDC_SPIN_TIMER,"msctls_updown32",UDS_SETBUDDYINT | UDS_ALIGNRIGHT | UDS_AUTOBUDDY | UDS_ARROWKEYS | UDS_NOTHOUSANDS,66,86,11,12 + LTEXT "minutes",IDC_STATIC,88,88,25,8 + PUSHBUTTON "&Advanced",IDC_ADVANCED_BREAK,192,102,41,14 + LTEXT "Enter timer mode by using the ZoomIt tray icon's Break menu item. Increase and decrease time with the arrow keys. If you Alt-Tab away from the timer window, reactivate it by left-clicking on the ZoomIt tray icon. Exit timer mode with Escape. ",IDC_STATIC,7,7,230,33 LTEXT "Start Timer:",IDC_STATIC,7,70,39,8 LTEXT "Timer:",IDC_STATIC,7,88,20,8 - LTEXT "Change the break timer color using the same keys that the drawing color. The break timer font is the same as text font.",IDC_STATIC,7,45,219,20 + LTEXT "Change the break timer color using the same keys that the drawing color. The break timer font is the same as text font.",IDC_STATIC,7,45,230,20 CONTROL "Show Time Elapsed After Expiration:",IDC_CHECK_SHOW_EXPIRED, "Button",BS_AUTOCHECKBOX | BS_LEFTTEXT | WS_TABSTOP,8,104,132,10 END @@ -251,69 +250,90 @@ BEGIN END LIVEZOOM DIALOGEX 0, 0, 260, 134 -STYLE DS_SETFONT | DS_FIXEDSYS | DS_CONTROL | WS_CHILD | WS_SYSMENU +STYLE DS_SETFONT | DS_CONTROL | WS_CHILD | WS_SYSMENU FONT 8, "MS Shell Dlg", 400, 0, 0x1 BEGIN CONTROL "",IDC_LIVE_HOTKEY,"msctls_hotkey32",WS_BORDER | WS_TABSTOP,69,108,80,12 - LTEXT "LiveZoom mode is supported on Windows 7 and higher where window updates show while zoomed. ",IDC_STATIC,7,7,246,18 + LTEXT "LiveZoom mode is supported on Windows 7 and higher where window updates show while zoomed. ",IDC_STATIC,7,7,230,18 LTEXT "LiveZoom Toggle:",IDC_STATIC,7,110,62,8 - LTEXT "To enter and exit LiveZoom, enter the hotkey specified below.",IDC_STATIC,7,94,218,13 - LTEXT "Note that in LiveZoom you must use Ctrl+Up and Ctrl+Down to control the zoom level. To enter drawing mode, use the standard zoom-without-draw hotkey and then escape to go back to LiveZoom.",IDC_STATIC,7,30,246,27 - LTEXT "Use LiveDraw to draw and annotate the live desktop. To activate LiveDraw, enter the hotkey with the Shift key in the opposite mode. You can remove LiveDraw annotations by activating LiveDraw and enter the escape key",IDC_STATIC,7,62,246,32 + LTEXT "To enter and exit LiveZoom, enter the hotkey specified below.",IDC_STATIC,7,94,230,13 + LTEXT "Note that in LiveZoom you must use Ctrl+Up and Ctrl+Down to control the zoom level. To enter drawing mode, use the standard zoom-without-draw hotkey and then escape to go back to LiveZoom.",IDC_STATIC,7,30,230,27 + LTEXT "Use LiveDraw to draw and annotate the live desktop. To activate LiveDraw, enter the hotkey with the Shift key in the opposite mode. You can remove LiveDraw annotations by activating LiveDraw and enter the escape key",IDC_STATIC,7,62,230,32 END -RECORD DIALOGEX 0, 0, 260, 169 +RECORD DIALOGEX 0, 0, 260, 181 STYLE DS_SETFONT | DS_FIXEDSYS | DS_CONTROL | WS_CHILD | WS_SYSMENU FONT 8, "MS Shell Dlg", 400, 0, 0x1 BEGIN CONTROL "",IDC_RECORD_HOTKEY,"msctls_hotkey32",WS_BORDER | WS_TABSTOP,61,96,80,12 LTEXT "Record Toggle:",IDC_STATIC,7,98,54,8 - LTEXT "Record video of the unzoomed live screen or a static zoomed session by entering the recording hot key and finish the recording by entering it again. ",IDC_STATIC,7,7,246,28 - LTEXT "Note: Recording is only available on Windows 10 (version 1903) and higher.",IDC_STATIC,7,77,246,19 + LTEXT "Record video of the unzoomed live screen or a static zoomed session by entering the recording hot key and finish the recording by entering it again. ",IDC_STATIC,7,7,248,28 + LTEXT "Note: Recording is only available on Windows 10 (version 1903) and higher.",IDC_STATIC,7,77,249,19 LTEXT "Scaling:",IDC_STATIC,30,115,26,8 COMBOBOX IDC_RECORD_SCALING,61,114,26,30,CBS_DROPDOWNLIST | CBS_AUTOHSCROLL | CBS_OEMCONVERT | CBS_SORT | WS_VSCROLL | WS_TABSTOP LTEXT "Format:",IDC_STATIC,30,132,26,8 COMBOBOX IDC_RECORD_FORMAT,61,131,60,30,CBS_DROPDOWNLIST | CBS_AUTOHSCROLL | CBS_OEMCONVERT | WS_VSCROLL | WS_TABSTOP LTEXT "Frame Rate:",IDC_STATIC,119,115,44,8,NOT WS_VISIBLE COMBOBOX IDC_RECORD_FRAME_RATE,166,114,42,30,CBS_DROPDOWNLIST | CBS_AUTOHSCROLL | CBS_OEMCONVERT | CBS_SORT | NOT WS_VISIBLE | WS_VSCROLL | WS_TABSTOP - LTEXT "To crop the portion of the screen that will be recorded, enter the hotkey with the Shift key in the opposite mode. ",IDC_STATIC,7,32,246,19 - LTEXT "To record a specific window, enter the hotkey with the Alt key in the opposite mode.",IDC_STATIC,7,55,246,19 - CONTROL "&Capture audio input:",IDC_CAPTURE_AUDIO,"Button",BS_AUTOCHECKBOX | BS_LEFTTEXT | WS_TABSTOP,7,149,83,10 - COMBOBOX IDC_MICROPHONE,81,164,172,30,CBS_DROPDOWNLIST | WS_VSCROLL | WS_TABSTOP - LTEXT "Microphone:",IDC_STATIC,32,166,47,8 + LTEXT "To crop the portion of the screen that will be recorded, enter the hotkey with the Shift key in the opposite mode. ",IDC_STATIC,7,35,245,19 + LTEXT "To record a specific window, enter the hotkey with the Alt key in the opposite mode.",IDC_STATIC,7,55,251,19 + CONTROL "Capture &system audio",IDC_CAPTURE_SYSTEM_AUDIO,"Button",BS_AUTOCHECKBOX | BS_LEFTTEXT | WS_TABSTOP,7,149,83,10 + CONTROL "&Capture audio input:",IDC_CAPTURE_AUDIO,"Button",BS_AUTOCHECKBOX | BS_LEFTTEXT | WS_TABSTOP,7,161,83,10 + COMBOBOX IDC_MICROPHONE,81,176,152,30,CBS_DROPDOWNLIST | WS_VSCROLL | WS_TABSTOP + LTEXT "Microphone:",IDC_MICROPHONE_LABEL,32,178,47,8 END SNIP DIALOGEX 0, 0, 260, 68 -STYLE DS_SETFONT | DS_FIXEDSYS | DS_CONTROL | WS_CHILD | WS_CLIPSIBLINGS | WS_SYSMENU +STYLE DS_SETFONT | DS_CONTROL | WS_CHILD | WS_CLIPSIBLINGS | WS_SYSMENU FONT 8, "MS Shell Dlg", 400, 0, 0x1 BEGIN CONTROL "",IDC_SNIP_HOTKEY,"msctls_hotkey32",WS_BORDER | WS_TABSTOP,55,32,80,12 LTEXT "Snip Toggle:",IDC_STATIC,7,33,45,8 - LTEXT "Copy a region of the screen to the clipboard or enter the hotkey with the Shift key in the opposite mode to save it to a file. ",IDC_STATIC,7,7,246,19 + LTEXT "Copy a region of the screen to the clipboard or enter the hotkey with the Shift key in the opposite mode to save it to a file. ",IDC_STATIC,7,7,230,19 END -DEMOTYPE DIALOGEX 0, 0, 259, 249 -STYLE DS_SETFONT | DS_FIXEDSYS | DS_CONTROL | WS_CHILD | WS_CLIPSIBLINGS | WS_SYSMENU +DEMOTYPE DIALOGEX 0, 0, 260, 249 +STYLE DS_SETFONT | DS_CONTROL | WS_CHILD | WS_CLIPSIBLINGS | WS_SYSMENU FONT 8, "MS Shell Dlg", 400, 0, 0x1 BEGIN CONTROL "",IDC_DEMOTYPE_HOTKEY,"msctls_hotkey32",WS_BORDER | WS_TABSTOP,74,154,80,12 LTEXT "DemoType toggle:",IDC_STATIC,7,157,63,8 - PUSHBUTTON "&...",IDC_DEMOTYPE_BROWSE,231,137,16,13 + PUSHBUTTON "&...",IDC_DEMOTYPE_BROWSE,211,137,16,13 CONTROL "",IDC_DEMOTYPE_SPEED_SLIDER,"msctls_trackbar32",TBS_AUTOTICKS | TBS_BOTH | TBS_NOTICKS | WS_TABSTOP,52,202,150,11,WS_EX_TRANSPARENT CONTROL "Drive input with typing:",IDC_DEMOTYPE_USER_DRIVEN, "Button",BS_AUTOCHECKBOX | BS_LEFTTEXT | WS_TABSTOP,7,173,88,10 - LTEXT "DemoType typing speed:",IDC_STATIC,7,189,215,10 + LTEXT "DemoType typing speed:",IDC_STATIC,7,189,230,10 LTEXT "Slow",IDC_DEMOTYPE_STATIC1,51,213,18,8 LTEXT "Fast",IDC_DEMOTYPE_STATIC2,186,213,17,8 - EDITTEXT IDC_DEMOTYPE_FILE,44,137,187,12,ES_AUTOHSCROLL | ES_READONLY + EDITTEXT IDC_DEMOTYPE_FILE,44,137,167,12,ES_AUTOHSCROLL | ES_READONLY LTEXT "Input file:",IDC_STATIC,7,139,32,8 - LTEXT "When you reach the end of the file, ZoomIt will reload the file and start at the beginning. Enter the hotkey with the Shift key in the opposite mode to step back to the last [end].",IDC_STATIC,7,108,248,24 - LTEXT "DemoType has ZoomIt type text specified in the input file when you enter the DemoType toggle. Simply separate snippets with the [end] keyword, or you can insert text from the clipboard if it is prefixed with the [start].",IDC_STATIC,7,7,248,24 - LTEXT " - Insert pauses with the [pause:n] keyword where 'n' is seconds. ",IDC_STATIC,19,34,212,11 - LTEXT "You can have ZoomIt send text automatically, or select the option to drive input with typing. ZoomIt will block keyboard input while sending output.",IDC_STATIC,7,68,248,16 - LTEXT "When driving input, hit the space bar to unblock keyboard input at the end of a snippet. In auto mode, control will be returned upon completion.",IDC_STATIC,7,88,248,16 - LTEXT "- Send text via the clipboard with [paste] and [/paste]. ",IDC_STATIC,23,45,178,8 - LTEXT "- Send keystrokes with [enter], [up], [down], [left], and [right].",IDC_STATIC,23,56,211,8 + LTEXT "When you reach the end of the file, ZoomIt will reload the file and start at the beginning. Enter the hotkey with the Shift key in the opposite mode to step back to the last [end].",IDC_STATIC,7,108,230,24 + LTEXT "DemoType has ZoomIt type text specified in the input file when you enter the DemoType toggle. Simply separate snippets with the [end] keyword, or you can insert text from the clipboard if it is prefixed with the [start].",IDC_STATIC,7,7,230,24 + LTEXT " - Insert pauses with the [pause:n] keyword where 'n' is seconds. ",IDC_STATIC,19,34,218,11 + LTEXT "You can have ZoomIt send text automatically, or select the option to drive input with typing. ZoomIt will block keyboard input while sending output.",IDC_STATIC,7,68,230,16 + LTEXT "When driving input, hit the space bar to unblock keyboard input at the end of a snippet. In auto mode, control will be returned upon completion.",IDC_STATIC,7,88,230,16 + LTEXT "- Send text via the clipboard with [paste] and [/paste]. ",IDC_STATIC,23,45,210,8 + LTEXT "- Send keystrokes with [enter], [up], [down], [left], and [right].",IDC_STATIC,23,56,210,8 +END + +IDD_VIDEO_TRIM DIALOGEX 0, 0, 521, 380 +STYLE DS_SETFONT | DS_MODALFRAME | DS_FIXEDSYS | DS_CENTER | WS_POPUP | WS_CAPTION | WS_SYSMENU | WS_THICKFRAME +CAPTION "ZoomIt Video Trim" +FONT 8, "MS Shell Dlg", 400, 0, 0x1 +BEGIN + LTEXT "",IDC_TRIM_DURATION_LABEL,12,267,160,8 + CONTROL "",IDC_TRIM_PREVIEW,"Static",SS_OWNERDRAW | SS_NOTIFY,12,12,498,244 + CTEXT "00:00.000",IDC_TRIM_POSITION_LABEL,155,267,200,8 + CONTROL "",IDC_TRIM_TIMELINE,"Static",SS_OWNERDRAW | SS_NOTIFY,11,277,498,47,WS_EX_TRANSPARENT + CONTROL "",IDC_TRIM_SKIP_START,"Button",BS_OWNERDRAW | WS_TABSTOP,183,327,30,26 + CONTROL "",IDC_TRIM_REWIND,"Button",BS_OWNERDRAW | WS_TABSTOP,215,327,30,26 + CONTROL "",IDC_TRIM_PLAY_PAUSE,"Button",BS_OWNERDRAW | WS_TABSTOP,247,325,44,32 + CONTROL "",IDC_TRIM_FORWARD,"Button",BS_OWNERDRAW | WS_TABSTOP,293,327,30,26 + CONTROL "",IDC_TRIM_SKIP_END,"Button",BS_OWNERDRAW | WS_TABSTOP,325,327,30,26 + CONTROL "",IDC_TRIM_VOLUME_ICON,"Static",SS_OWNERDRAW | SS_NOTIFY,365,334,14,12 + CONTROL "",IDC_TRIM_VOLUME,"msctls_trackbar32",TBS_NOTICKS | WS_TABSTOP,380,333,70,14 + DEFPUSHBUTTON "OK",IDOK,404,358,50,14 + PUSHBUTTON "Cancel",IDCANCEL,458,358,50,14 END @@ -327,7 +347,7 @@ GUIDELINES DESIGNINFO BEGIN "OPTIONS", DIALOG BEGIN - RIGHTMARGIN, 273 + RIGHTMARGIN, 293 BOTTOMMARGIN, 320 END @@ -340,7 +360,6 @@ BEGIN "ZOOM", DIALOG BEGIN LEFTMARGIN, 7 - RIGHTMARGIN, 253 TOPMARGIN, 7 BOTTOMMARGIN, 151 END @@ -348,7 +367,6 @@ BEGIN "DRAW", DIALOG BEGIN LEFTMARGIN, 7 - RIGHTMARGIN, 253 TOPMARGIN, 7 BOTTOMMARGIN, 221 END @@ -356,7 +374,6 @@ BEGIN "TYPE", DIALOG BEGIN LEFTMARGIN, 7 - RIGHTMARGIN, 253 TOPMARGIN, 7 BOTTOMMARGIN, 97 END @@ -364,7 +381,6 @@ BEGIN "BREAK", DIALOG BEGIN LEFTMARGIN, 7 - RIGHTMARGIN, 253 TOPMARGIN, 7 BOTTOMMARGIN, 116 END @@ -378,7 +394,6 @@ BEGIN "LIVEZOOM", DIALOG BEGIN LEFTMARGIN, 7 - RIGHTMARGIN, 253 TOPMARGIN, 7 BOTTOMMARGIN, 127 END @@ -386,7 +401,6 @@ BEGIN "RECORD", DIALOG BEGIN LEFTMARGIN, 7 - RIGHTMARGIN, 253 TOPMARGIN, 7 BOTTOMMARGIN, 164 END @@ -394,7 +408,6 @@ BEGIN "SNIP", DIALOG BEGIN LEFTMARGIN, 7 - RIGHTMARGIN, 253 TOPMARGIN, 7 BOTTOMMARGIN, 61 END @@ -402,10 +415,13 @@ BEGIN "DEMOTYPE", DIALOG BEGIN LEFTMARGIN, 7 - RIGHTMARGIN, 255 TOPMARGIN, 7 BOTTOMMARGIN, 205 END + + IDD_VIDEO_TRIM, DIALOG + BEGIN + END END #endif // APSTUDIO_INVOKED @@ -474,6 +490,11 @@ BEGIN 0 END +IDD_VIDEO_TRIM AFX_DIALOG_LAYOUT +BEGIN + 0 +END + #endif // English (United States) resources ///////////////////////////////////////////////////////////////////////////// diff --git a/src/modules/ZoomIt/ZoomIt/ZoomIt.vcxproj b/src/modules/ZoomIt/ZoomIt/ZoomIt.vcxproj index 77c299f303..6dbacd0016 100644 --- a/src/modules/ZoomIt/ZoomIt/ZoomIt.vcxproj +++ b/src/modules/ZoomIt/ZoomIt/ZoomIt.vcxproj @@ -216,6 +216,14 @@ false false + + false + false + false + false + false + false + NotUsing NotUsing @@ -293,6 +301,7 @@ + diff --git a/src/modules/ZoomIt/ZoomIt/ZoomIt.vcxproj.filters b/src/modules/ZoomIt/ZoomIt/ZoomIt.vcxproj.filters index e0416fe585..2bd93a7095 100644 --- a/src/modules/ZoomIt/ZoomIt/ZoomIt.vcxproj.filters +++ b/src/modules/ZoomIt/ZoomIt/ZoomIt.vcxproj.filters @@ -33,6 +33,9 @@ Source Files + + Source Files + Source Files @@ -80,6 +83,9 @@ Header Files + + Header Files + Header Files diff --git a/src/modules/ZoomIt/ZoomIt/ZoomItSettings.h b/src/modules/ZoomIt/ZoomIt/ZoomItSettings.h index efd731cdce..e7176e6ec8 100644 --- a/src/modules/ZoomIt/ZoomIt/ZoomItSettings.h +++ b/src/modules/ZoomIt/ZoomIt/ZoomItSettings.h @@ -49,8 +49,15 @@ DWORD g_RecordScaling = 100; DWORD g_RecordScalingGIF = 50; DWORD g_RecordScalingMP4 = 100; RecordingFormat g_RecordingFormat = RecordingFormat::MP4; +BOOLEAN g_CaptureSystemAudio = TRUE; BOOLEAN g_CaptureAudio = FALSE; TCHAR g_MicrophoneDeviceId[MAX_PATH] = {0}; +TCHAR g_RecordingSaveLocationBuffer[MAX_PATH] = {0}; +TCHAR g_ScreenshotSaveLocationBuffer[MAX_PATH] = {0}; +DWORD g_ThemeOverride = 2; // 0=light, 1=dark, 2=system default +DWORD g_TrimDialogWidth = 0; // 0 means use default; stored in screen pixels +DWORD g_TrimDialogHeight = 0; // 0 means use default; stored in screen pixels +DWORD g_TrimDialogVolume = 70; // 0-100 volume level for trim dialog preview REG_SETTING RegSettings[] = { { L"ToggleKey", SETTING_TYPE_DWORD, 0, &g_ToggleKey, static_cast(g_ToggleKey) }, @@ -91,6 +98,13 @@ REG_SETTING RegSettings[] = { { L"RecordScalingGIF", SETTING_TYPE_DWORD, 0, &g_RecordScalingGIF, static_cast(g_RecordScalingGIF) }, { L"RecordScalingMP4", SETTING_TYPE_DWORD, 0, &g_RecordScalingMP4, static_cast(g_RecordScalingMP4) }, { L"CaptureAudio", SETTING_TYPE_BOOLEAN, 0, &g_CaptureAudio, static_cast(g_CaptureAudio) }, + { L"CaptureSystemAudio", SETTING_TYPE_BOOLEAN, 0, &g_CaptureSystemAudio, static_cast(g_CaptureSystemAudio) }, { L"MicrophoneDeviceId", SETTING_TYPE_STRING, sizeof(g_MicrophoneDeviceId), g_MicrophoneDeviceId, static_cast(0) }, + { L"RecordingSaveLocation", SETTING_TYPE_STRING, sizeof(g_RecordingSaveLocationBuffer), g_RecordingSaveLocationBuffer, static_cast(0) }, + { L"ScreenshotSaveLocation", SETTING_TYPE_STRING, sizeof(g_ScreenshotSaveLocationBuffer), g_ScreenshotSaveLocationBuffer, static_cast(0) }, + { L"Theme", SETTING_TYPE_DWORD, 0, &g_ThemeOverride, static_cast(g_ThemeOverride) }, + { L"TrimDialogWidth", SETTING_TYPE_DWORD, 0, &g_TrimDialogWidth, static_cast(0) }, + { L"TrimDialogHeight", SETTING_TYPE_DWORD, 0, &g_TrimDialogHeight, static_cast(0) }, + { L"TrimDialogVolume", SETTING_TYPE_DWORD, 0, &g_TrimDialogVolume, static_cast(g_TrimDialogVolume) }, { NULL, SETTING_TYPE_DWORD, 0, NULL, static_cast(0) } }; diff --git a/src/modules/ZoomIt/ZoomIt/Zoomit.cpp b/src/modules/ZoomIt/ZoomIt/Zoomit.cpp index e38ca07f66..68731f1a98 100644 --- a/src/modules/ZoomIt/ZoomIt/Zoomit.cpp +++ b/src/modules/ZoomIt/ZoomIt/Zoomit.cpp @@ -85,6 +85,10 @@ COLORREF g_CustomColors[16]; #define DEMOTYPE_RESET_HOTKEY 11 #define RECORD_GIF_HOTKEY 12 #define RECORD_GIF_WINDOW_HOTKEY 13 +#define SAVE_IMAGE_HOTKEY 14 +#define SAVE_CROP_HOTKEY 15 +#define COPY_IMAGE_HOTKEY 16 +#define COPY_CROP_HOTKEY 17 #define ZOOM_PAGE 0 #define LIVE_PAGE 1 @@ -177,12 +181,13 @@ BOOLEAN g_running = TRUE; // Screen recording globals #define DEFAULT_RECORDING_FILE L"Recording.mp4" #define DEFAULT_GIF_RECORDING_FILE L"Recording.gif" +#define DEFAULT_SCREENSHOT_FILE L"ZoomIt.png" BOOL g_RecordToggle = FALSE; BOOL g_RecordCropping = FALSE; SelectRectangle g_SelectRectangle; std::wstring g_RecordingSaveLocation; -std::wstring g_RecordingSaveLocationGIF; +std::wstring g_ScreenshotSaveLocation; winrt::IDirect3DDevice g_RecordDevice{ nullptr }; std::shared_ptr g_RecordingSession = nullptr; std::shared_ptr g_GifRecordingSession = nullptr; @@ -217,6 +222,74 @@ ClassRegistry reg( _T("Software\\Sysinternals\\") APPNAME ); ComputerGraphicsInit g_GraphicsInit; +// Event handler to set icon and extended style on dialog creation +class OpenSaveDialogEvents : public IFileDialogEvents +{ +public: + OpenSaveDialogEvents(bool showOnTaskbar = true) : m_refCount(1), m_initialized(false), m_showOnTaskbar(showOnTaskbar) {} + + // IUnknown + IFACEMETHODIMP QueryInterface(REFIID riid, void** ppv) + { + static const QITAB qit[] = { + QITABENT(OpenSaveDialogEvents, IFileDialogEvents), + { 0 }, + }; + return QISearch(this, qit, riid, ppv); + } + IFACEMETHODIMP_(ULONG) AddRef() { return InterlockedIncrement(&m_refCount); } + IFACEMETHODIMP_(ULONG) Release() + { + ULONG count = InterlockedDecrement(&m_refCount); + if (count == 0) delete this; + return count; + } + + // IFileDialogEvents + IFACEMETHODIMP OnFileOk(IFileDialog*) { return S_OK; } + IFACEMETHODIMP OnFolderChange(IFileDialog* pfd) + { + if (!m_initialized) + { + m_initialized = true; + wil::com_ptr pWindow; + if (SUCCEEDED(pfd->QueryInterface(IID_PPV_ARGS(&pWindow)))) + { + HWND hwndDialog = nullptr; + if (SUCCEEDED(pWindow->GetWindow(&hwndDialog)) && hwndDialog) + { + if (m_showOnTaskbar) + { + // Set WS_EX_APPWINDOW extended style + LONG_PTR exStyle = GetWindowLongPtr(hwndDialog, GWL_EXSTYLE); + SetWindowLongPtr(hwndDialog, GWL_EXSTYLE, exStyle | WS_EX_APPWINDOW); + } + + // Set the dialog icon + HICON hIcon = LoadIcon(g_hInstance, L"APPICON"); + if (hIcon) + { + SendMessage(hwndDialog, WM_SETICON, ICON_BIG, reinterpret_cast(hIcon)); + SendMessage(hwndDialog, WM_SETICON, ICON_SMALL, reinterpret_cast(hIcon)); + } + } + } + } + return S_OK; + } + IFACEMETHODIMP OnFolderChanging(IFileDialog*, IShellItem*) { return S_OK; } + IFACEMETHODIMP OnSelectionChange(IFileDialog*) { return S_OK; } + IFACEMETHODIMP OnShareViolation(IFileDialog*, IShellItem*, FDE_SHAREVIOLATION_RESPONSE*) { return S_OK; } + IFACEMETHODIMP OnTypeChange(IFileDialog*) { return S_OK; } + IFACEMETHODIMP OnOverwrite(IFileDialog*, IShellItem*, FDE_OVERWRITE_RESPONSE*) { return S_OK; } + +private: + LONG m_refCount; + bool m_initialized; + bool m_showOnTaskbar; +}; + + //---------------------------------------------------------------------------- // // Saves specified filePath to clipboard. @@ -1415,7 +1488,7 @@ HBITMAP LoadImageFile( PTCHAR Filename ) // Use gdi+ to save a PNG. // //---------------------------------------------------------------------------- -DWORD SavePng( PTCHAR Filename, HBITMAP hBitmap ) +DWORD SavePng( LPCTSTR Filename, HBITMAP hBitmap ) { Gdiplus::Bitmap bitmap( hBitmap, NULL ); CLSID pngClsid; @@ -1554,10 +1627,19 @@ INT_PTR CALLBACK AdvancedBreakProc( HWND hDlg, UINT message, WPARAM wParam, LPAR static TCHAR newBackgroundFile[MAX_PATH]; TCHAR filePath[MAX_PATH], initDir[MAX_PATH]; DWORD i; - OPENFILENAME openFileName; + static UINT currentDpi = DPI_BASELINE; switch ( message ) { case WM_INITDIALOG: + // Set the dialog icon + { + HICON hIcon = LoadIcon( g_hInstance, L"APPICON" ); + if( hIcon ) + { + SendMessage( hDlg, WM_SETICON, ICON_BIG, reinterpret_cast(hIcon) ); + SendMessage( hDlg, WM_SETICON, ICON_SMALL, reinterpret_cast(hIcon) ); + } + } if( pSHAutoComplete ) { pSHAutoComplete( GetDlgItem( hDlg, IDC_SOUND_FILE), SHACF_FILESYSTEM ); pSHAutoComplete( GetDlgItem( hDlg, IDC_BACKGROUND_FILE), SHACF_FILESYSTEM ); @@ -1617,8 +1699,49 @@ INT_PTR CALLBACK AdvancedBreakProc( HWND hDlg, UINT message, WPARAM wParam, LPAR } SendMessage( GetDlgItem( hDlg, IDC_OPACITY ), CB_SETCURSEL, g_BreakOpacity / 10 - 1, 0 ); + + // Apply DPI scaling to the dialog + currentDpi = GetDpiForWindowHelper( hDlg ); + if( currentDpi != DPI_BASELINE ) + { + ScaleDialogForDpi( hDlg, currentDpi, DPI_BASELINE ); + } + + // Apply dark mode + ApplyDarkModeToDialog( hDlg ); return TRUE; + case WM_DPICHANGED: + HandleDialogDpiChange( hDlg, wParam, lParam, currentDpi ); + return TRUE; + + case WM_ERASEBKGND: + if (IsDarkModeEnabled()) + { + HDC hdc = reinterpret_cast(wParam); + RECT rc; + GetClientRect(hDlg, &rc); + FillRect(hdc, &rc, GetDarkModeBrush()); + return TRUE; + } + break; + + case WM_CTLCOLORDLG: + case WM_CTLCOLORSTATIC: + case WM_CTLCOLORBTN: + case WM_CTLCOLOREDIT: + case WM_CTLCOLORLISTBOX: + { + HDC hdc = reinterpret_cast(wParam); + HWND hCtrl = reinterpret_cast(lParam); + HBRUSH hBrush = HandleDarkModeCtlColor(hdc, hCtrl, message); + if (hBrush) + { + return reinterpret_cast(hBrush); + } + break; + } + case WM_COMMAND: switch ( HIWORD( wParam )) { case BN_CLICKED: @@ -1648,77 +1771,126 @@ INT_PTR CALLBACK AdvancedBreakProc( HWND hDlg, UINT message, WPARAM wParam, LPAR } switch ( LOWORD( wParam )) { case IDC_SOUND_BROWSE: - memset( &openFileName, 0, sizeof(openFileName )); - openFileName.lStructSize = OPENFILENAME_SIZE_VERSION_400; - openFileName.hwndOwner = hDlg; - openFileName.hInstance = static_cast(g_hInstance); - openFileName.nMaxFile = sizeof(filePath)/sizeof(filePath[0]); - openFileName.Flags = OFN_LONGNAMES; - openFileName.lpstrTitle = L"Specify sound file..."; - openFileName.lpstrDefExt = L"*.wav"; - openFileName.nFilterIndex = 1; - openFileName.lpstrFilter = L"Sounds\0*.wav\0All Files\0*.*\0"; + { + auto openDialog = wil::CoCreateInstance( CLSID_FileOpenDialog ); - GetDlgItemText( hDlg, IDC_SOUND_FILE, filePath, sizeof(filePath )); - if( _tcsrchr( filePath, '\\' )) { + FILEOPENDIALOGOPTIONS options; + if( SUCCEEDED( openDialog->GetOptions( &options ) ) ) + openDialog->SetOptions( options | FOS_FORCEFILESYSTEM ); + COMDLG_FILTERSPEC fileTypes[] = { + { L"Sounds", L"*.wav" }, + { L"All Files", L"*.*" } + }; + openDialog->SetFileTypes( _countof( fileTypes ), fileTypes ); + openDialog->SetFileTypeIndex( 1 ); + openDialog->SetDefaultExtension( L"wav" ); + openDialog->SetTitle( L"ZoomIt: Specify Sound File..." ); + + // Set initial folder + GetDlgItemText( hDlg, IDC_SOUND_FILE, filePath, _countof( filePath ) ); + if( _tcsrchr( filePath, '\\' ) ) + { _tcscpy( initDir, filePath ); - _tcscpy( filePath, _tcsrchr( initDir, '\\' )+1); - *(_tcsrchr( initDir, '\\' )+1) = 0; - } else { - + *( _tcsrchr( initDir, '\\' ) + 1 ) = 0; + } + else + { _tcscpy( filePath, L"%WINDIR%\\Media" ); - ExpandEnvironmentStrings( filePath, initDir, sizeof(initDir)/sizeof(initDir[0])); - GetDlgItemText( hDlg, IDC_SOUND_FILE, filePath, sizeof(filePath )); + ExpandEnvironmentStrings( filePath, initDir, _countof( initDir ) ); + } + wil::com_ptr folderItem; + if( SUCCEEDED( SHCreateItemFromParsingName( initDir, nullptr, IID_PPV_ARGS( &folderItem ) ) ) ) + { + openDialog->SetFolder( folderItem.get() ); } - openFileName.lpstrInitialDir = initDir; - openFileName.lpstrFile = filePath; - if( GetOpenFileName( &openFileName )) { - _tcscpy( newSoundFile, filePath ); - if(_tcsrchr( filePath, '\\' )) _tcscpy( filePath, _tcsrchr( newSoundFile, '\\' )+1); - if(_tcsrchr( filePath, '.' )) *_tcsrchr( filePath, '.' ) = 0; - SetDlgItemText( hDlg, IDC_SOUND_FILE, filePath ); + OpenSaveDialogEvents* pEvents = new OpenSaveDialogEvents(false); + DWORD dwCookie = 0; + openDialog->Advise( pEvents, &dwCookie ); + + if( SUCCEEDED( openDialog->Show( hDlg ) ) ) + { + wil::com_ptr resultItem; + if( SUCCEEDED( openDialog->GetResult( &resultItem ) ) ) + { + wil::unique_cotaskmem_string pathStr; + if( SUCCEEDED( resultItem->GetDisplayName( SIGDN_FILESYSPATH, &pathStr ) ) ) + { + _tcscpy( newSoundFile, pathStr.get() ); + _tcscpy( filePath, pathStr.get() ); + if( _tcsrchr( filePath, '\\' ) ) _tcscpy( filePath, _tcsrchr( filePath, '\\' ) + 1 ); + if( _tcsrchr( filePath, '.' ) ) *_tcsrchr( filePath, '.' ) = 0; + SetDlgItemText( hDlg, IDC_SOUND_FILE, filePath ); + } + } } + + openDialog->Unadvise( dwCookie ); + pEvents->Release(); break; + } case IDC_BACKGROUND_BROWSE: - memset( &openFileName, 0, sizeof(openFileName )); - openFileName.lStructSize = OPENFILENAME_SIZE_VERSION_400; - openFileName.hwndOwner = hDlg; - openFileName.hInstance = static_cast(g_hInstance); - openFileName.nMaxFile = sizeof(filePath)/sizeof(filePath[0]); - openFileName.Flags = OFN_LONGNAMES; - openFileName.lpstrTitle = L"Specify background file..."; - openFileName.lpstrDefExt = L"*.bmp"; - openFileName.nFilterIndex = 5; - openFileName.lpstrFilter = L"Bitmap Files (*.bmp;*.dib)\0*.bmp;*.dib\0" - "PNG (*.png)\0*.png\0" - "JPEG (*.jpg;*.jpeg;*.jpe;*.jfif)\0*.jpg;*.jpeg;*.jpe;*.jfif\0" - "GIF (*.gif)\0*.gif\0" - "All Picture Files\0.bmp;*.dib;*.png;*.jpg;*.jpeg;*.jpe;*.jfif;*.gif)\0" - "All Files\0*.*\0\0"; + { + auto openDialog = wil::CoCreateInstance( CLSID_FileOpenDialog ); - GetDlgItemText( hDlg, IDC_BACKGROUND_FILE, filePath, sizeof(filePath )); - if(_tcsrchr( filePath, '\\' )) { + FILEOPENDIALOGOPTIONS options; + if( SUCCEEDED( openDialog->GetOptions( &options ) ) ) + openDialog->SetOptions( options | FOS_FORCEFILESYSTEM ); + COMDLG_FILTERSPEC fileTypes[] = { + { L"Bitmap Files (*.bmp;*.dib)", L"*.bmp;*.dib" }, + { L"PNG (*.png)", L"*.png" }, + { L"JPEG (*.jpg;*.jpeg;*.jpe;*.jfif)", L"*.jpg;*.jpeg;*.jpe;*.jfif" }, + { L"GIF (*.gif)", L"*.gif" }, + { L"All Picture Files", L"*.bmp;*.dib;*.png;*.jpg;*.jpeg;*.jpe;*.jfif;*.gif" }, + { L"All Files", L"*.*" } + }; + openDialog->SetFileTypes( _countof( fileTypes ), fileTypes ); + openDialog->SetFileTypeIndex( 5 ); // Default to "All Picture Files" + openDialog->SetTitle( L"ZoomIt: Specify Background File..." ); + + // Set initial folder + GetDlgItemText( hDlg, IDC_BACKGROUND_FILE, filePath, _countof( filePath ) ); + if( _tcsrchr( filePath, '\\' ) ) + { _tcscpy( initDir, filePath ); - _tcscpy( filePath, _tcsrchr( initDir, '\\' )+1); - *(_tcsrchr( initDir, '\\' )+1) = 0; - } else { - + *( _tcsrchr( initDir, '\\' ) + 1 ) = 0; + } + else + { _tcscpy( filePath, L"%USERPROFILE%\\Pictures" ); - ExpandEnvironmentStrings( filePath, initDir, sizeof(initDir)/sizeof(initDir[0])); - GetDlgItemText( hDlg, IDC_BACKGROUND_FILE, filePath, sizeof(filePath )); + ExpandEnvironmentStrings( filePath, initDir, _countof( initDir ) ); + } + wil::com_ptr folderItem; + if( SUCCEEDED( SHCreateItemFromParsingName( initDir, nullptr, IID_PPV_ARGS( &folderItem ) ) ) ) + { + openDialog->SetFolder( folderItem.get() ); } - openFileName.lpstrInitialDir = initDir; - openFileName.lpstrFile = filePath; - if( GetOpenFileName( &openFileName )) { - _tcscpy( newBackgroundFile, filePath ); - SetDlgItemText( hDlg, IDC_BACKGROUND_FILE, filePath ); + OpenSaveDialogEvents* pEvents = new OpenSaveDialogEvents(false); + DWORD dwCookie = 0; + openDialog->Advise( pEvents, &dwCookie ); + + if( SUCCEEDED( openDialog->Show( hDlg ) ) ) + { + wil::com_ptr resultItem; + if( SUCCEEDED( openDialog->GetResult( &resultItem ) ) ) + { + wil::unique_cotaskmem_string pathStr; + if( SUCCEEDED( resultItem->GetDisplayName( SIGDN_FILESYSPATH, &pathStr ) ) ) + { + _tcscpy( newBackgroundFile, pathStr.get() ); + SetDlgItemText( hDlg, IDC_BACKGROUND_FILE, pathStr.get() ); + } + } } + + openDialog->Unadvise( dwCookie ); + pEvents->Release(); break; + } case IDOK: @@ -1780,6 +1952,45 @@ INT_PTR CALLBACK AdvancedBreakProc( HWND hDlg, UINT message, WPARAM wParam, LPAR // OptionsTabProc // //---------------------------------------------------------------------------- + +static UINT_PTR CALLBACK ChooseFontHookProc(HWND hDlg, UINT message, WPARAM wParam, LPARAM lParam) +{ + switch (message) + { + case WM_INITDIALOG: + // Set the dialog icon + { + HICON hIcon = LoadIcon( g_hInstance, L"APPICON" ); + if( hIcon ) + { + SendMessage( hDlg, WM_SETICON, ICON_BIG, reinterpret_cast(hIcon) ); + SendMessage( hDlg, WM_SETICON, ICON_SMALL, reinterpret_cast(hIcon) ); + } + } + // Basic (incomplete) dark mode attempt: theme the main common dialog window. + ApplyDarkModeToDialog(hDlg); + return 0; + + case WM_CTLCOLORDLG: + case WM_CTLCOLORSTATIC: + case WM_CTLCOLORBTN: + case WM_CTLCOLOREDIT: + case WM_CTLCOLORLISTBOX: + { + HDC hdc = reinterpret_cast(wParam); + HWND hCtrl = reinterpret_cast(lParam); + HBRUSH hBrush = HandleDarkModeCtlColor(hdc, hCtrl, message); + if (hBrush) + { + return reinterpret_cast(hBrush); + } + break; + } + } + + return 0; +} + INT_PTR CALLBACK OptionsTabProc( HWND hDlg, UINT message, WPARAM wParam, LPARAM lParam ) { @@ -1791,12 +2002,38 @@ INT_PTR CALLBACK OptionsTabProc( HWND hDlg, UINT message, HWND hTextPreview; HDC hDc; RECT previewRc; - TCHAR filePath[MAX_PATH] = {0}; - OPENFILENAME openFileName; switch ( message ) { case WM_INITDIALOG: return TRUE; + + case WM_ERASEBKGND: + if (IsDarkModeEnabled()) + { + HDC hdc = reinterpret_cast(wParam); + RECT rc; + GetClientRect(hDlg, &rc); + FillRect(hdc, &rc, GetDarkModeBrush()); + return TRUE; + } + break; + + case WM_CTLCOLORDLG: + case WM_CTLCOLORSTATIC: + case WM_CTLCOLORBTN: + case WM_CTLCOLOREDIT: + case WM_CTLCOLORLISTBOX: + { + HDC hdc = reinterpret_cast(wParam); + HWND hCtrl = reinterpret_cast(lParam); + HBRUSH hBrush = HandleDarkModeCtlColor(hdc, hCtrl, message); + if (hBrush) + { + return reinterpret_cast(hBrush); + } + break; + } + case WM_COMMAND: // Handle combo box selection changes if (HIWORD(wParam) == CBN_SELCHANGE) { @@ -1826,7 +2063,7 @@ INT_PTR CALLBACK OptionsTabProc( HWND hDlg, UINT message, // Check if GIF is selected by comparing the text bool isGifSelected = (wcscmp(selectedText, L"GIF") == 0); - // If GIF is selected, set the scaling to the g_RecordScalingGIF value; otherwise to the g_RecordScalingMP4 value + // If GIF is selected, set the scaling to the g_RecordScalingGIF value if (isGifSelected) { g_RecordScaling = g_RecordScalingGIF; @@ -1844,9 +2081,11 @@ INT_PTR CALLBACK OptionsTabProc( HWND hDlg, UINT message, } } - // Enable/disable microphone controls based on selection - EnableWindow(GetDlgItem(hDlg, IDC_MICROPHONE), !isGifSelected); + // Enable/disable audio controls based on selection (GIF has no audio) + EnableWindow(GetDlgItem(hDlg, IDC_CAPTURE_SYSTEM_AUDIO), !isGifSelected); EnableWindow(GetDlgItem(hDlg, IDC_CAPTURE_AUDIO), !isGifSelected); + EnableWindow(GetDlgItem(hDlg, IDC_MICROPHONE_LABEL), !isGifSelected); + EnableWindow(GetDlgItem(hDlg, IDC_MICROPHONE), !isGifSelected); } } @@ -1863,7 +2102,7 @@ INT_PTR CALLBACK OptionsTabProc( HWND hDlg, UINT message, chooseFont.lStructSize = sizeof (CHOOSEFONT); chooseFont.hwndOwner = hDlg; chooseFont.lpLogFont = &lf; - chooseFont.Flags = CF_SCREENFONTS|CF_ENABLETEMPLATE| + chooseFont.Flags = CF_SCREENFONTS|CF_ENABLETEMPLATE|CF_ENABLEHOOK| CF_INITTOLOGFONTSTRUCT|CF_LIMITSIZE; chooseFont.rgbColors = RGB (0, 0, 0); chooseFont.lCustData = 0; @@ -1872,7 +2111,7 @@ INT_PTR CALLBACK OptionsTabProc( HWND hDlg, UINT message, chooseFont.hInstance = g_hInstance; chooseFont.lpszStyle = static_cast(NULL); chooseFont.nFontType = SCREEN_FONTTYPE; - chooseFont.lpfnHook = reinterpret_cast(static_cast(NULL)); + chooseFont.lpfnHook = ChooseFontHookProc; chooseFont.lpTemplateName = static_cast(MAKEINTRESOURCE (FORMATDLGORD31)); if( ChooseFont( &chooseFont ) ) { g_LogFont = lf; @@ -1880,31 +2119,50 @@ INT_PTR CALLBACK OptionsTabProc( HWND hDlg, UINT message, } break; case IDC_DEMOTYPE_BROWSE: - memset( &openFileName, 0, sizeof( openFileName ) ); - openFileName.lStructSize = OPENFILENAME_SIZE_VERSION_400; - openFileName.hwndOwner = hDlg; - openFileName.hInstance = static_cast(g_hInstance); - openFileName.nMaxFile = sizeof( filePath ) / sizeof( filePath[0] ); - openFileName.Flags = OFN_LONGNAMES; - openFileName.lpstrTitle = L"Specify DemoType file..."; - openFileName.nFilterIndex = 1; - openFileName.lpstrFilter = L"All Files\0*.*\0\0"; - openFileName.lpstrFile = filePath; + { + auto openDialog = wil::CoCreateInstance( CLSID_FileOpenDialog ); - if( GetOpenFileName( &openFileName ) ) + FILEOPENDIALOGOPTIONS options; + if( SUCCEEDED( openDialog->GetOptions( &options ) ) ) + openDialog->SetOptions( options | FOS_FORCEFILESYSTEM ); + + COMDLG_FILTERSPEC fileTypes[] = { + { L"All Files", L"*.*" } + }; + openDialog->SetFileTypes( _countof( fileTypes ), fileTypes ); + openDialog->SetFileTypeIndex( 1 ); + openDialog->SetTitle( L"ZoomIt: Specify DemoType File..." ); + + OpenSaveDialogEvents* pEvents = new OpenSaveDialogEvents(false); + DWORD dwCookie = 0; + openDialog->Advise( pEvents, &dwCookie ); + + if( SUCCEEDED( openDialog->Show( hDlg ) ) ) { - if( GetFileAttributes( filePath ) == -1 ) + wil::com_ptr resultItem; + if( SUCCEEDED( openDialog->GetResult( &resultItem ) ) ) { - MessageBox( hDlg, L"The specified file is inaccessible", APPNAME, MB_ICONERROR ); - } - else - { - SetDlgItemText( g_OptionsTabs[DEMOTYPE_PAGE].hPage, IDC_DEMOTYPE_FILE, filePath ); - _tcscpy( g_DemoTypeFile, filePath ); + wil::unique_cotaskmem_string pathStr; + if( SUCCEEDED( resultItem->GetDisplayName( SIGDN_FILESYSPATH, &pathStr ) ) ) + { + if( GetFileAttributes( pathStr.get() ) == INVALID_FILE_ATTRIBUTES ) + { + MessageBox( hDlg, L"The specified file is inaccessible", APPNAME, MB_ICONERROR ); + } + else + { + SetDlgItemText( g_OptionsTabs[DEMOTYPE_PAGE].hPage, IDC_DEMOTYPE_FILE, pathStr.get() ); + _tcscpy( g_DemoTypeFile, pathStr.get() ); + } + } } } + + openDialog->Unadvise( dwCookie ); + pEvents->Release(); break; } + } break; case WM_PAINT: @@ -1921,7 +2179,15 @@ INT_PTR CALLBACK OptionsTabProc( HWND hDlg, UINT message, MapWindowPoints( NULL, hDlg, reinterpret_cast(&previewRc), 2); previewRc.top += 6; - DrawText( hDc, L"Sample", static_cast(_tcslen(L"Sample")), &previewRc, + + // Set text color based on dark mode + if (IsDarkModeEnabled()) + { + SetTextColor(hDc, DarkMode::TextColor); + SetBkMode(hDc, TRANSPARENT); + } + + DrawText( hDc, L"AaBbYyZz", static_cast(_tcslen(L"AaBbYyZz")), &previewRc, DT_CENTER|DT_VCENTER|DT_SINGLELINE ); EndPaint( hDlg, &ps ); @@ -1935,6 +2201,46 @@ INT_PTR CALLBACK OptionsTabProc( HWND hDlg, UINT message, } +//---------------------------------------------------------------------------- +// +// RepositionTabPages +// +// Reposition tab pages to fit current tab control size (call after DPI change) +// +//---------------------------------------------------------------------------- +VOID RepositionTabPages( HWND hTabCtrl ) +{ + RECT rc, pageRc; + GetWindowRect( hTabCtrl, &rc ); + TabCtrl_AdjustRect( hTabCtrl, FALSE, &rc ); + + // Inset the display area to leave room for border in dark mode + if (IsDarkModeEnabled()) + { + rc.left += 2; + rc.top += 2; + rc.right -= 2; + rc.bottom -= 2; + } + + // Get the parent dialog to convert coordinates correctly + HWND hParent = GetParent( hTabCtrl ); + + for( int i = 0; i < sizeof( g_OptionsTabs )/sizeof(g_OptionsTabs[0]); i++ ) { + if( g_OptionsTabs[i].hPage ) { + pageRc = rc; + // Convert screen coords to parent dialog client coords + MapWindowPoints( NULL, hParent, reinterpret_cast(&pageRc), 2); + + SetWindowPos( g_OptionsTabs[i].hPage, + HWND_TOP, + pageRc.left, pageRc.top, + pageRc.right - pageRc.left, pageRc.bottom - pageRc.top, + SWP_NOACTIVATE | SWP_NOZORDER ); + } + } +} + //---------------------------------------------------------------------------- // // OptionsAddTabs @@ -1955,23 +2261,47 @@ VOID OptionsAddTabs( HWND hOptionsDlg, HWND hTabCtrl ) g_OptionsTabs[i].hPage = CreateDialog( g_hInstance, g_OptionsTabs[i].TabTitle, hOptionsDlg, OptionsTabProc ); } + TabCtrl_AdjustRect( hTabCtrl, FALSE, &rc ); + + // Inset the display area to leave room for border in dark mode + // Need 2 pixels so tab pages don't cover the 1-pixel border + if (IsDarkModeEnabled()) + { + rc.left += 2; + rc.top += 2; + rc.right -= 2; + rc.bottom -= 2; + } + for( i = 0; i < sizeof( g_OptionsTabs )/sizeof(g_OptionsTabs[0]); i++ ) { pageRc = rc; - MapWindowPoints( NULL, g_OptionsTabs[i].hPage, reinterpret_cast(&pageRc), 2); + // Convert screen coords to parent dialog client coords. + MapWindowPoints( NULL, hOptionsDlg, reinterpret_cast(&pageRc), 2); SetWindowPos( g_OptionsTabs[i].hPage, HWND_TOP, pageRc.left, pageRc.top, pageRc.right - pageRc.left, pageRc.bottom - pageRc.top, - SWP_NOACTIVATE|(i == 0 ? SWP_SHOWWINDOW : SWP_HIDEWINDOW)); + SWP_NOACTIVATE | SWP_HIDEWINDOW ); if( pEnableThemeDialogTexture ) { - - pEnableThemeDialogTexture( g_OptionsTabs[i].hPage, ETDT_ENABLETAB ); + if( IsDarkModeEnabled() ) { + // Disable theme dialog texture in dark mode - it interferes with dark backgrounds + pEnableThemeDialogTexture( g_OptionsTabs[i].hPage, ETDT_DISABLE ); + } else { + // Enable tab texturing in light mode + pEnableThemeDialogTexture( g_OptionsTabs[i].hPage, ETDT_ENABLETAB ); + } } } + + // Show the initial page once positioned to reduce visible churn. + if( g_OptionsTabs[0].hPage ) + { + ShowWindow( g_OptionsTabs[0].hPage, SW_SHOW ); + } } //---------------------------------------------------------------------------- @@ -1995,6 +2325,10 @@ void UnregisterAllHotkeys( HWND hWnd ) UnregisterHotKey( hWnd, DEMOTYPE_RESET_HOTKEY ); UnregisterHotKey( hWnd, RECORD_GIF_HOTKEY ); UnregisterHotKey( hWnd, RECORD_GIF_WINDOW_HOTKEY ); + UnregisterHotKey( hWnd, SAVE_IMAGE_HOTKEY ); + UnregisterHotKey( hWnd, SAVE_CROP_HOTKEY ); + UnregisterHotKey( hWnd, COPY_IMAGE_HOTKEY ); + UnregisterHotKey( hWnd, COPY_CROP_HOTKEY ); } //---------------------------------------------------------------------------- @@ -2027,6 +2361,10 @@ void RegisterAllHotkeys(HWND hWnd) // Register CTRL+8 for GIF recording and CTRL+ALT+8 for GIF window recording RegisterHotKey(hWnd, RECORD_GIF_HOTKEY, MOD_CONTROL | MOD_NOREPEAT, 568 && 0xFF); RegisterHotKey(hWnd, RECORD_GIF_WINDOW_HOTKEY, MOD_CONTROL | MOD_ALT | MOD_NOREPEAT, 568 && 0xFF); + + // Note: COPY_IMAGE_HOTKEY, COPY_CROP_HOTKEY (Ctrl+C, Ctrl+Shift+C) and + // SAVE_IMAGE_HOTKEY, SAVE_CROP_HOTKEY (Ctrl+S, Ctrl+Shift+S) are registered + // only during static zoom mode to avoid blocking system-wide Ctrl+C/Ctrl+S } @@ -2041,26 +2379,68 @@ void UpdateDrawTabHeaderFont() static HFONT headerFont = nullptr; TCHAR text[64]; - if( headerFont != nullptr ) + constexpr int headers[] = { IDC_PEN_CONTROL, IDC_COLORS, IDC_HIGHLIGHT_AND_BLUR, IDC_SHAPES, IDC_SCREEN }; + + HWND hPage = g_OptionsTabs[DRAW_PAGE].hPage; + if( !hPage ) { - DeleteObject( headerFont ); - headerFont = nullptr; + return; } - constexpr int headers[] = { IDC_PEN_CONTROL, IDC_COLORS, IDC_HIGHLIGHT_AND_BLUR, IDC_SHAPES, IDC_SCREEN }; + // Get the font from an actual body text control that has been DPI-scaled. + // This ensures headers use the exact same font as body text, just bold. + // Find the first static text child control (ID -1) to get the scaled body text font. + HFONT hBaseFont = nullptr; + HWND hChild = GetWindow( hPage, GW_CHILD ); + while( hChild != nullptr ) + { + if( GetDlgCtrlID( hChild ) == -1 ) // IDC_STATIC is -1 + { + hBaseFont = reinterpret_cast(SendMessage( hChild, WM_GETFONT, 0, 0 )); + if( hBaseFont ) + { + break; + } + } + hChild = GetWindow( hChild, GW_HWNDNEXT ); + } + + if( !hBaseFont ) + { + hBaseFont = static_cast(GetStockObject( DEFAULT_GUI_FONT )); + } + + LOGFONT lf{}; + if( !GetObject( hBaseFont, sizeof( LOGFONT ), &lf ) ) + { + GetObject( GetStockObject( DEFAULT_GUI_FONT ), sizeof( LOGFONT ), &lf ); + } + lf.lfWeight = FW_BOLD; + + HFONT newHeaderFont = CreateFontIndirect( &lf ); + if( !newHeaderFont ) + { + return; + } + + // Swap fonts safely: apply the new font to all header controls first, then delete the old. + HFONT oldHeaderFont = headerFont; + headerFont = newHeaderFont; + for( int i = 0; i < _countof( headers ); i++ ) { // Change the header font to bold - HWND hHeader = GetDlgItem( g_OptionsTabs[DRAW_PAGE].hPage, headers[i] ); - if( headerFont == nullptr ) + HWND hHeader = GetDlgItem( hPage, headers[i] ); + if( !hHeader ) { - HFONT hFont = reinterpret_cast(SendMessage( hHeader, WM_GETFONT, 0, 0 )); - LOGFONT lf = {}; - GetObject( hFont, sizeof( LOGFONT ), &lf ); - lf.lfWeight = FW_BOLD; - headerFont = CreateFontIndirect( &lf ); + continue; } - SendMessage( hHeader, WM_SETFONT, reinterpret_cast(headerFont), 0 ); + + // StaticTextSubclassProc already supports a per-control font override via this property. + // Setting it here makes Draw tab headers resilient if something later overwrites WM_SETFONT. + SetPropW( hHeader, L"ZoomIt.HeaderFont", headerFont ); + + SendMessage( hHeader, WM_SETFONT, reinterpret_cast(headerFont), TRUE ); // Resize the control to fit the text GetWindowText( hHeader, text, sizeof( text ) / sizeof( text[0] ) ); @@ -2072,7 +2452,1104 @@ void UpdateDrawTabHeaderFont() DrawText( hDC, text, static_cast(_tcslen( text )), &rc, DT_CALCRECT | DT_SINGLELINE | DT_LEFT | DT_VCENTER ); ReleaseDC( hHeader, hDC ); SetWindowPos( hHeader, nullptr, 0, 0, rc.right - rc.left + ScaleForDpi( 4, GetDpiForWindowHelper( hHeader ) ), rc.bottom - rc.top, SWP_NOMOVE | SWP_NOZORDER ); + InvalidateRect( hHeader, nullptr, TRUE ); } + + if( oldHeaderFont ) + { + DeleteObject( oldHeaderFont ); + } +} + +//---------------------------------------------------------------------------- +// +// CheckboxSubclassProc +// +// Subclass procedure for checkbox and radio button controls to handle dark mode colors +// +//---------------------------------------------------------------------------- +LRESULT CALLBACK CheckboxSubclassProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam, UINT_PTR uIdSubclass, DWORD_PTR dwRefData) +{ + switch (uMsg) + { + case WM_PAINT: + if (IsDarkModeEnabled()) + { + TCHAR dbgText[256] = { 0 }; + GetWindowText(hWnd, dbgText, _countof(dbgText)); + bool dbgEnabled = IsWindowEnabled(hWnd); + LONG dbgStyle = GetWindowLong(hWnd, GWL_STYLE); + LONG dbgType = dbgStyle & BS_TYPEMASK; + bool dbgIsRadio = (dbgType == BS_RADIOBUTTON || dbgType == BS_AUTORADIOBUTTON); + OutputDebugStringW((std::wstring(L"[Checkbox] WM_PAINT: ") + dbgText + + L" enabled=" + (dbgEnabled ? L"1" : L"0") + + L" isRadio=" + (dbgIsRadio ? L"1" : L"0") + L"\n").c_str()); + + PAINTSTRUCT ps; + HDC hdc = BeginPaint(hWnd, &ps); + + RECT rc; + GetClientRect(hWnd, &rc); + + // Fill background + FillRect(hdc, &rc, GetDarkModeBrush()); + + // Get button state and style + LRESULT state = SendMessage(hWnd, BM_GETCHECK, 0, 0); + bool isChecked = (state == BST_CHECKED); + bool isEnabled = IsWindowEnabled(hWnd); + + // Check if this is a radio button + LONG style = GetWindowLong(hWnd, GWL_STYLE); + LONG buttonType = style & BS_TYPEMASK; + bool isRadioButton = (buttonType == BS_RADIOBUTTON || buttonType == BS_AUTORADIOBUTTON); + + // Check if checkbox should be on the right (BS_LEFTTEXT or WS_EX_RIGHT) + bool checkOnRight = (style & BS_LEFTTEXT) != 0; + LONG exStyle = GetWindowLong(hWnd, GWL_EXSTYLE); + if (exStyle & WS_EX_RIGHT) + checkOnRight = true; + + // Get DPI for scaling + UINT dpi = GetDpiForWindowHelper(hWnd); + int checkSize = ScaleForDpi(13, dpi); + int margin = ScaleForDpi(2, dpi); + + // Calculate checkbox/radio position + RECT rcCheck; + if (checkOnRight) + { + rcCheck.right = rc.right - margin; + rcCheck.left = rcCheck.right - checkSize; + } + else + { + rcCheck.left = rc.left + margin; + rcCheck.right = rcCheck.left + checkSize; + } + rcCheck.top = rc.top + (rc.bottom - rc.top - checkSize) / 2; + rcCheck.bottom = rcCheck.top + checkSize; + + // Choose colors based on enabled state + COLORREF borderColor = isEnabled ? DarkMode::BorderColor : RGB(60, 60, 60); + COLORREF fillColor = isChecked ? (isEnabled ? DarkMode::AccentColor : RGB(80, 80, 85)) : DarkMode::SurfaceColor; + COLORREF textColor = isEnabled ? DarkMode::TextColor : RGB(100, 100, 100); + + if (isRadioButton) + { + // Draw radio button (circle) + HPEN hPen = CreatePen(PS_SOLID, 1, borderColor); + HPEN hOldPen = static_cast(SelectObject(hdc, hPen)); + HBRUSH hFillBrush = CreateSolidBrush(isChecked ? fillColor : DarkMode::SurfaceColor); + HBRUSH hOldBrush = static_cast(SelectObject(hdc, hFillBrush)); + Ellipse(hdc, rcCheck.left, rcCheck.top, rcCheck.right, rcCheck.bottom); + SelectObject(hdc, hOldBrush); + SelectObject(hdc, hOldPen); + DeleteObject(hPen); + DeleteObject(hFillBrush); + + // Draw inner circle if checked + if (isChecked) + { + int innerMargin = ScaleForDpi(3, dpi); + HBRUSH hInnerBrush = CreateSolidBrush(isEnabled ? RGB(255, 255, 255) : RGB(140, 140, 140)); + HBRUSH hOldInnerBrush = static_cast(SelectObject(hdc, hInnerBrush)); + HPEN hNullPen = static_cast(SelectObject(hdc, GetStockObject(NULL_PEN))); + Ellipse(hdc, rcCheck.left + innerMargin, rcCheck.top + innerMargin, + rcCheck.right - innerMargin, rcCheck.bottom - innerMargin); + SelectObject(hdc, hNullPen); + SelectObject(hdc, hOldInnerBrush); + DeleteObject(hInnerBrush); + } + } + else + { + // Draw checkbox (rectangle) + HPEN hPen = CreatePen(PS_SOLID, 1, borderColor); + HPEN hOldPen = static_cast(SelectObject(hdc, hPen)); + HBRUSH hFillBrush = CreateSolidBrush(fillColor); + HBRUSH hOldBrush = static_cast(SelectObject(hdc, hFillBrush)); + Rectangle(hdc, rcCheck.left, rcCheck.top, rcCheck.right, rcCheck.bottom); + SelectObject(hdc, hOldBrush); + SelectObject(hdc, hOldPen); + DeleteObject(hPen); + DeleteObject(hFillBrush); + + // Draw checkmark if checked + if (isChecked) + { + COLORREF checkColor = isEnabled ? RGB(255, 255, 255) : RGB(140, 140, 140); + HPEN hCheckPen = CreatePen(PS_SOLID, ScaleForDpi(2, dpi), checkColor); + HPEN hOldCheckPen = static_cast(SelectObject(hdc, hCheckPen)); + + // Draw checkmark + int x = rcCheck.left + ScaleForDpi(3, dpi); + int y = rcCheck.top + ScaleForDpi(6, dpi); + MoveToEx(hdc, x, y, nullptr); + LineTo(hdc, x + ScaleForDpi(2, dpi), y + ScaleForDpi(3, dpi)); + LineTo(hdc, x + ScaleForDpi(7, dpi), y - ScaleForDpi(3, dpi)); + + SelectObject(hdc, hOldCheckPen); + DeleteObject(hCheckPen); + } + } + + // Draw text + TCHAR text[256] = { 0 }; + GetWindowText(hWnd, text, _countof(text)); + + SetBkMode(hdc, TRANSPARENT); + SetTextColor(hdc, textColor); + HFONT hFont = reinterpret_cast(SendMessage(hWnd, WM_GETFONT, 0, 0)); + HFONT hOldFont = nullptr; + if (hFont) + { + hOldFont = static_cast(SelectObject(hdc, hFont)); + } + + RECT rcText = rc; + UINT textFormat = DT_VCENTER | DT_SINGLELINE; + if (checkOnRight) + { + rcText.right = rcCheck.left - ScaleForDpi(4, dpi); + textFormat |= DT_RIGHT; + } + else + { + rcText.left = rcCheck.right + ScaleForDpi(4, dpi); + textFormat |= DT_LEFT; + } + DrawText(hdc, text, -1, &rcText, textFormat); + + if (hOldFont) + { + SelectObject(hdc, hOldFont); + } + + EndPaint(hWnd, &ps); + return 0; + } + break; + + case WM_ENABLE: + if (IsDarkModeEnabled()) + { + // Let base window proc handle enable state change, but avoid any subclass chain + // that might trigger themed drawing + LRESULT result = DefWindowProc(hWnd, uMsg, wParam, lParam); + // Force immediate repaint with our custom painting + InvalidateRect(hWnd, nullptr, TRUE); + UpdateWindow(hWnd); + return result; + } + break; + + case WM_NCDESTROY: + RemoveWindowSubclass(hWnd, CheckboxSubclassProc, uIdSubclass); + break; + } + return DefSubclassProc(hWnd, uMsg, wParam, lParam); +} + +//---------------------------------------------------------------------------- +// +// HotkeyControlSubclassProc +// +// Subclass procedure for hotkey controls to handle dark mode colors +// +//---------------------------------------------------------------------------- +LRESULT CALLBACK HotkeyControlSubclassProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam, UINT_PTR uIdSubclass, DWORD_PTR dwRefData) +{ + switch (uMsg) + { + case WM_PAINT: + if (IsDarkModeEnabled()) + { + // Get the hotkey from the control using HKM_GETHOTKEY + LRESULT hk = SendMessage(hWnd, HKM_GETHOTKEY, 0, 0); + WORD hotkey = LOWORD(hk); + BYTE vk = LOBYTE(hotkey); + BYTE mods = HIBYTE(hotkey); + + // Build the hotkey text + std::wstring text; + if (vk != 0) + { + if (mods & HOTKEYF_CONTROL) + text += L"Ctrl+"; + if (mods & HOTKEYF_SHIFT) + text += L"Shift+"; + if (mods & HOTKEYF_ALT) + text += L"Alt+"; + + // Get key name using virtual key code + UINT scanCode = MapVirtualKeyW(vk, MAPVK_VK_TO_VSC); + if (scanCode != 0) + { + TCHAR keyName[64] = { 0 }; + LONG lParamKey = (scanCode << 16); + // Set extended key bit for certain keys + if ((vk >= VK_PRIOR && vk <= VK_DELETE) || + (vk >= VK_LWIN && vk <= VK_APPS) || + vk == VK_DIVIDE || vk == VK_NUMLOCK) + { + lParamKey |= (1 << 24); + } + if (GetKeyNameTextW(lParamKey, keyName, _countof(keyName)) > 0) + { + text += keyName; + } + else + { + // Fallback: use the virtual key character for printable keys + if (vk >= '0' && vk <= '9') + { + text += static_cast(vk); + } + else if (vk >= 'A' && vk <= 'Z') + { + text += static_cast(vk); + } + else if (vk >= VK_F1 && vk <= VK_F24) + { + text += L"F"; + text += std::to_wstring(vk - VK_F1 + 1); + } + } + } + else + { + // No scan code, try direct character representation + if (vk >= '0' && vk <= '9') + { + text += static_cast(vk); + } + else if (vk >= 'A' && vk <= 'Z') + { + text += static_cast(vk); + } + else if (vk >= VK_F1 && vk <= VK_F24) + { + text += L"F"; + text += std::to_wstring(vk - VK_F1 + 1); + } + } + } + + PAINTSTRUCT ps; + HDC hdc = BeginPaint(hWnd, &ps); + + RECT rc; + GetClientRect(hWnd, &rc); + + // Fill background with dark surface color + FillRect(hdc, &rc, GetDarkModeSurfaceBrush()); + + // No border in dark mode - just the filled background + + // Draw text if we have any + if (!text.empty()) + { + SetBkMode(hdc, TRANSPARENT); + SetTextColor(hdc, DarkMode::TextColor); + HFONT hFont = reinterpret_cast(SendMessage(hWnd, WM_GETFONT, 0, 0)); + HFONT hOldFont = nullptr; + if (hFont) + { + hOldFont = static_cast(SelectObject(hdc, hFont)); + } + RECT rcText = rc; + rcText.left += 4; + rcText.right -= 4; + DrawTextW(hdc, text.c_str(), -1, &rcText, DT_LEFT | DT_VCENTER | DT_SINGLELINE); + if (hOldFont) + { + SelectObject(hdc, hOldFont); + } + } + + EndPaint(hWnd, &ps); + return 0; + } + break; + + case WM_NCPAINT: + if (IsDarkModeEnabled()) + { + // Fill the non-client area with background color to hide the border + HDC hdc = GetWindowDC(hWnd); + if (hdc) + { + RECT rc; + GetWindowRect(hWnd, &rc); + int width = rc.right - rc.left; + int height = rc.bottom - rc.top; + rc.left = 0; + rc.top = 0; + rc.right = width; + rc.bottom = height; + + // Get NC border size + RECT rcClient; + GetClientRect(hWnd, &rcClient); + MapWindowPoints(hWnd, nullptr, reinterpret_cast(&rcClient), 2); + + RECT rcWindow; + GetWindowRect(hWnd, &rcWindow); + + int borderLeft = rcClient.left - rcWindow.left; + int borderTop = rcClient.top - rcWindow.top; + int borderRight = rcWindow.right - rcClient.right; + int borderBottom = rcWindow.bottom - rcClient.bottom; + + // Fill the entire NC border area with background color + HBRUSH hBrush = CreateSolidBrush(DarkMode::BackgroundColor); + + // Top border + RECT rcTop = { 0, 0, width, borderTop }; + FillRect(hdc, &rcTop, hBrush); + + // Bottom border + RECT rcBottom = { 0, height - borderBottom, width, height }; + FillRect(hdc, &rcBottom, hBrush); + + // Left border + RECT rcLeft = { 0, borderTop, borderLeft, height - borderBottom }; + FillRect(hdc, &rcLeft, hBrush); + + // Right border + RECT rcRight = { width - borderRight, borderTop, width, height - borderBottom }; + FillRect(hdc, &rcRight, hBrush); + + DeleteObject(hBrush); + + // Draw thin border around the control + HPEN hPen = CreatePen(PS_SOLID, 1, DarkMode::BorderColor); + HPEN hOldPen = static_cast(SelectObject(hdc, hPen)); + HBRUSH hOldBrush = static_cast(SelectObject(hdc, GetStockObject(NULL_BRUSH))); + Rectangle(hdc, 0, 0, width, height); + SelectObject(hdc, hOldBrush); + SelectObject(hdc, hOldPen); + DeleteObject(hPen); + + ReleaseDC(hWnd, hdc); + } + return 0; + } + break; + + case WM_NCDESTROY: + RemoveWindowSubclass(hWnd, HotkeyControlSubclassProc, uIdSubclass); + break; + } + return DefSubclassProc(hWnd, uMsg, wParam, lParam); +} + +//---------------------------------------------------------------------------- +// +// EditControlSubclassProc +// +// Subclass procedure for edit controls to handle dark mode (no border) +// +//---------------------------------------------------------------------------- +LRESULT CALLBACK EditControlSubclassProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam, UINT_PTR uIdSubclass, DWORD_PTR dwRefData) +{ + // Helper to adjust formatting rectangle for vertical text centering + auto AdjustTextRect = [](HWND hEdit) { + RECT rcClient; + GetClientRect(hEdit, &rcClient); + + // Get font metrics to calculate text height + HDC hdc = GetDC(hEdit); + HFONT hFont = reinterpret_cast(SendMessage(hEdit, WM_GETFONT, 0, 0)); + HFONT hOldFont = hFont ? static_cast(SelectObject(hdc, hFont)) : nullptr; + + TEXTMETRIC tm; + GetTextMetrics(hdc, &tm); + int textHeight = tm.tmHeight; + + if (hOldFont) + SelectObject(hdc, hOldFont); + ReleaseDC(hEdit, hdc); + + // Calculate vertical offset to center text + int clientHeight = rcClient.bottom - rcClient.top; + int topOffset = (clientHeight - textHeight) / 2; + if (topOffset < 0) topOffset = 0; + + RECT rcFormat = rcClient; + rcFormat.top = topOffset; + rcFormat.left += 2; // Small left margin + rcFormat.right -= 2; // Small right margin + + SendMessage(hEdit, EM_SETRECT, 0, reinterpret_cast(&rcFormat)); + }; + + switch (uMsg) + { + case WM_SIZE: + { + // Adjust the formatting rectangle to vertically center text + LRESULT result = DefSubclassProc(hWnd, uMsg, wParam, lParam); + AdjustTextRect(hWnd); + return result; + } + + case WM_SETFONT: + { + // After font is set, adjust formatting rectangle + LRESULT result = DefSubclassProc(hWnd, uMsg, wParam, lParam); + AdjustTextRect(hWnd); + return result; + } + + case WM_NCPAINT: + if (IsDarkModeEnabled()) + { + OutputDebugStringW(L"[Edit] WM_NCPAINT in dark mode\n"); + + // Get the window DC which includes NC area + HDC hdc = GetWindowDC(hWnd); + if (hdc) + { + RECT rc; + GetWindowRect(hWnd, &rc); + int width = rc.right - rc.left; + int height = rc.bottom - rc.top; + rc.left = 0; + rc.top = 0; + rc.right = width; + rc.bottom = height; + + // Get NC border size + RECT rcClient; + GetClientRect(hWnd, &rcClient); + MapWindowPoints(hWnd, nullptr, reinterpret_cast(&rcClient), 2); + + RECT rcWindow; + GetWindowRect(hWnd, &rcWindow); + + int borderLeft = rcClient.left - rcWindow.left; + int borderTop = rcClient.top - rcWindow.top; + int borderRight = rcWindow.right - rcClient.right; + int borderBottom = rcWindow.bottom - rcClient.bottom; + + OutputDebugStringW((L"[Edit] Border: L=" + std::to_wstring(borderLeft) + L" T=" + std::to_wstring(borderTop) + + L" R=" + std::to_wstring(borderRight) + L" B=" + std::to_wstring(borderBottom) + L"\n").c_str()); + + // Fill the entire NC border area with background color + HBRUSH hBrush = CreateSolidBrush(DarkMode::BackgroundColor); + + // Top border + RECT rcTop = { 0, 0, width, borderTop }; + FillRect(hdc, &rcTop, hBrush); + + // Bottom border + RECT rcBottom = { 0, height - borderBottom, width, height }; + FillRect(hdc, &rcBottom, hBrush); + + // Left border + RECT rcLeft = { 0, borderTop, borderLeft, height - borderBottom }; + FillRect(hdc, &rcLeft, hBrush); + + // Right border + RECT rcRight = { width - borderRight, borderTop, width, height - borderBottom }; + FillRect(hdc, &rcRight, hBrush); + + DeleteObject(hBrush); + + // Draw thin border around the control + HPEN hPen = CreatePen(PS_SOLID, 1, DarkMode::BorderColor); + HPEN hOldPen = static_cast(SelectObject(hdc, hPen)); + HBRUSH hOldBrush = static_cast(SelectObject(hdc, GetStockObject(NULL_BRUSH))); + Rectangle(hdc, 0, 0, width, height); + SelectObject(hdc, hOldBrush); + SelectObject(hdc, hOldPen); + DeleteObject(hPen); + + ReleaseDC(hWnd, hdc); + } + return 0; + } + break; + + case WM_NCDESTROY: + RemoveWindowSubclass(hWnd, EditControlSubclassProc, uIdSubclass); + break; + } + return DefSubclassProc(hWnd, uMsg, wParam, lParam); +} + +//---------------------------------------------------------------------------- +// +// SliderSubclassProc +// +// Subclass procedure for slider/trackbar controls to handle dark mode +// +//---------------------------------------------------------------------------- +LRESULT CALLBACK SliderSubclassProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam, UINT_PTR uIdSubclass, DWORD_PTR dwRefData) +{ + switch (uMsg) + { + case WM_LBUTTONDOWN: + case WM_MOUSEMOVE: + case WM_LBUTTONUP: + if (IsDarkModeEnabled()) + { + // Let the default handler process the message first + LRESULT result = DefSubclassProc(hWnd, uMsg, wParam, lParam); + // Force full repaint to avoid artifacts at high DPI + InvalidateRect(hWnd, nullptr, TRUE); + return result; + } + break; + + case WM_PAINT: + if (IsDarkModeEnabled()) + { + PAINTSTRUCT ps; + HDC hdc = BeginPaint(hWnd, &ps); + + RECT rc; + GetClientRect(hWnd, &rc); + + // Fill background + FillRect(hdc, &rc, GetDarkModeBrush()); + + // Get slider info + RECT rcChannel = { 0 }; + SendMessage(hWnd, TBM_GETCHANNELRECT, 0, reinterpret_cast(&rcChannel)); + + RECT rcThumb = { 0 }; + SendMessage(hWnd, TBM_GETTHUMBRECT, 0, reinterpret_cast(&rcThumb)); + + // Draw channel (track) - simple dark line + int channelHeight = 4; + int channelY = (rc.bottom + rc.top) / 2 - channelHeight / 2; + RECT rcTrack = { rcChannel.left, channelY, rcChannel.right, channelY + channelHeight }; + HBRUSH hTrackBrush = CreateSolidBrush(RGB(80, 80, 85)); + FillRect(hdc, &rcTrack, hTrackBrush); + DeleteObject(hTrackBrush); + + // Center thumb vertically - at high DPI the thumb rect may not be centered + int thumbHeight = rcThumb.bottom - rcThumb.top; + int thumbCenterY = (rc.bottom + rc.top) / 2; + rcThumb.top = thumbCenterY - thumbHeight / 2; + rcThumb.bottom = rcThumb.top + thumbHeight; + + // Draw thumb - dark rectangle + HBRUSH hThumbBrush = CreateSolidBrush(RGB(160, 160, 165)); + FillRect(hdc, &rcThumb, hThumbBrush); + DeleteObject(hThumbBrush); + + // Draw thumb border + HPEN hPen = CreatePen(PS_SOLID, 1, RGB(100, 100, 105)); + HPEN hOldPen = static_cast(SelectObject(hdc, hPen)); + HBRUSH hOldBrush = static_cast(SelectObject(hdc, GetStockObject(NULL_BRUSH))); + Rectangle(hdc, rcThumb.left, rcThumb.top, rcThumb.right, rcThumb.bottom); + SelectObject(hdc, hOldBrush); + SelectObject(hdc, hOldPen); + DeleteObject(hPen); + + EndPaint(hWnd, &ps); + return 0; + } + break; + + case WM_ERASEBKGND: + if (IsDarkModeEnabled()) + { + HDC hdc = reinterpret_cast(wParam); + RECT rc; + GetClientRect(hWnd, &rc); + FillRect(hdc, &rc, GetDarkModeBrush()); + return TRUE; + } + break; + + case WM_NCDESTROY: + RemoveWindowSubclass(hWnd, SliderSubclassProc, uIdSubclass); + break; + } + return DefSubclassProc(hWnd, uMsg, wParam, lParam); +} + +//---------------------------------------------------------------------------- +// +// GroupBoxSubclassProc +// +// Subclass procedure for group box controls to handle dark mode painting +// +//---------------------------------------------------------------------------- +LRESULT CALLBACK GroupBoxSubclassProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam, UINT_PTR uIdSubclass, DWORD_PTR dwRefData) +{ + switch (uMsg) + { + case WM_PAINT: + if (IsDarkModeEnabled()) + { + PAINTSTRUCT ps; + HDC hdc = BeginPaint(hWnd, &ps); + + RECT rc; + GetClientRect(hWnd, &rc); + + // Get the text and font + HFONT hFont = reinterpret_cast(SendMessage(hWnd, WM_GETFONT, 0, 0)); + HFONT hOldFont = hFont ? static_cast(SelectObject(hdc, hFont)) : nullptr; + + TCHAR szText[256] = { 0 }; + GetWindowText(hWnd, szText, _countof(szText)); + + // Measure text + SIZE textSize = { 0 }; + GetTextExtentPoint32(hdc, szText, static_cast(_tcslen(szText)), &textSize); + + // Text starts at left + 8 pixels + const int textLeft = 8; + const int textPadding = 4; + int frameTop = textSize.cy / 2; + + // Only fill the frame border areas, not the interior (to avoid painting over child controls) + // Fill top strip (above frame line) + RECT rcTop = { rc.left, rc.top, rc.right, frameTop + 1 }; + FillRect(hdc, &rcTop, GetDarkModeBrush()); + + // Fill left edge strip + RECT rcLeft = { rc.left, frameTop, rc.left + 1, rc.bottom }; + FillRect(hdc, &rcLeft, GetDarkModeBrush()); + + // Fill right edge strip + RECT rcRight = { rc.right - 1, frameTop, rc.right, rc.bottom }; + FillRect(hdc, &rcRight, GetDarkModeBrush()); + + // Fill bottom edge strip + RECT rcBottom = { rc.left, rc.bottom - 1, rc.right, rc.bottom }; + FillRect(hdc, &rcBottom, GetDarkModeBrush()); + + // Draw the group box frame (with gap for text) + HPEN hPen = CreatePen(PS_SOLID, 1, DarkMode::BorderColor); + HPEN hOldPen = static_cast(SelectObject(hdc, hPen)); + + // Top line - left segment (before text) + MoveToEx(hdc, rc.left, frameTop, NULL); + LineTo(hdc, textLeft - textPadding, frameTop); + + // Top line - right segment (after text) + MoveToEx(hdc, textLeft + textSize.cx + textPadding, frameTop, NULL); + LineTo(hdc, rc.right - 1, frameTop); + + // Right line + LineTo(hdc, rc.right - 1, rc.bottom - 1); + + // Bottom line + LineTo(hdc, rc.left, rc.bottom - 1); + + // Left line + LineTo(hdc, rc.left, frameTop); + + SelectObject(hdc, hOldPen); + DeleteObject(hPen); + + // Draw text with background + SetBkMode(hdc, OPAQUE); + SetBkColor(hdc, DarkMode::BackgroundColor); + SetTextColor(hdc, DarkMode::TextColor); + RECT rcText = { textLeft, 0, textLeft + textSize.cx, textSize.cy }; + DrawText(hdc, szText, -1, &rcText, DT_LEFT | DT_SINGLELINE); + + if (hOldFont) + SelectObject(hdc, hOldFont); + + EndPaint(hWnd, &ps); + return 0; + } + break; + + case WM_ERASEBKGND: + // Don't erase background - let parent handle it + if (IsDarkModeEnabled()) + { + return TRUE; + } + break; + + case WM_NCDESTROY: + RemoveWindowSubclass(hWnd, GroupBoxSubclassProc, uIdSubclass); + break; + } + return DefSubclassProc(hWnd, uMsg, wParam, lParam); +} + +//---------------------------------------------------------------------------- +// +// StaticTextSubclassProc +// +// Subclass procedure for static text controls to handle dark mode painting +// +//---------------------------------------------------------------------------- +LRESULT CALLBACK StaticTextSubclassProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam, UINT_PTR uIdSubclass, DWORD_PTR dwRefData) +{ + const int ctrlId = GetDlgCtrlID( hWnd ); + const bool isOptionsHeader = (ctrlId == IDC_VERSION || ctrlId == IDC_COPYRIGHT); + + auto paintStaticText = [](HWND hWnd, HDC hdc) -> void + { + RECT rc; + GetClientRect(hWnd, &rc); + + // Fill background + if( IsDarkModeEnabled() ) + { + FillRect(hdc, &rc, GetDarkModeBrush()); + } + else + { + FillRect(hdc, &rc, GetSysColorBrush( COLOR_BTNFACE )); + } + + // Get text + TCHAR text[512] = { 0 }; + GetWindowText(hWnd, text, _countof(text)); + + // Set up text drawing + SetBkMode(hdc, TRANSPARENT); + bool isEnabled = IsWindowEnabled(hWnd); + if( IsDarkModeEnabled() ) + { + SetTextColor(hdc, isEnabled ? DarkMode::TextColor : RGB(100, 100, 100)); + } + else + { + SetTextColor( hdc, isEnabled ? GetSysColor( COLOR_WINDOWTEXT ) : GetSysColor( COLOR_GRAYTEXT ) ); + } + + // Try to get the font from a window property first (for header controls where + // WM_GETFONT may not work reliably), then fall back to WM_GETFONT. + HFONT hFont = static_cast(GetPropW( hWnd, L"ZoomIt.HeaderFont" )); + HFONT hCreatedFont = nullptr; // Track if we created a font that needs cleanup + + // For IDC_VERSION, create a large title font on-demand if the property font doesn't work + const int thisCtrlId = GetDlgCtrlID( hWnd ); + if( thisCtrlId == IDC_VERSION ) + { + // Create a title font that is proportionally larger than the dialog font + LOGFONT lf{}; + HFONT hDialogFont = reinterpret_cast(SendMessage( GetParent( hWnd ), WM_GETFONT, 0, 0 )); + if( hDialogFont ) + { + GetObject( hDialogFont, sizeof( lf ), &lf ); + } + else + { + GetObject( GetStockObject( DEFAULT_GUI_FONT ), sizeof( lf ), &lf ); + } + lf.lfWeight = FW_BOLD; + // Make title 50% larger than dialog font (lfHeight is negative for character height) + lf.lfHeight = MulDiv( lf.lfHeight, 3, 2 ); + hCreatedFont = CreateFontIndirect( &lf ); + if( hCreatedFont ) + { + hFont = hCreatedFont; + } + } + + if( !hFont ) + { + hFont = reinterpret_cast(SendMessage(hWnd, WM_GETFONT, 0, 0)); + } + HFONT hOldFont = nullptr; + if (hFont) + { + hOldFont = static_cast(SelectObject(hdc, hFont)); + } + +#if _DEBUG + if( thisCtrlId == IDC_VERSION ) + { + TEXTMETRIC tm{}; + GetTextMetrics( hdc, &tm ); + OutputDebug(L"IDC_VERSION paint: tmHeight=%d selectResult=%p hFont=%p created=%p rc=(%d,%d,%d,%d)\n", + tm.tmHeight, hOldFont, hFont, hCreatedFont, rc.left, rc.top, rc.right, rc.bottom ); + } +#endif + + // Get style to determine alignment and wrapping behavior + LONG style = GetWindowLong(hWnd, GWL_STYLE); + const LONG staticType = style & SS_TYPEMASK; + + UINT format = 0; + if (style & SS_CENTER) + format |= DT_CENTER; + else if (style & SS_RIGHT) + format |= DT_RIGHT; + else + format |= DT_LEFT; + + if (style & SS_NOPREFIX) + format |= DT_NOPREFIX; + + bool noWrap = (staticType == SS_LEFTNOWORDWRAP) || (staticType == SS_SIMPLE); + if( GetDlgCtrlID( hWnd ) == IDC_VERSION ) + { + // The header title is intended to be a single line. + noWrap = true; + } + if (noWrap) + { + // Single-line labels should match the classic static control behavior. + format |= DT_SINGLELINE | DT_VCENTER | DT_END_ELLIPSIS; + } + else + { + // Multi-line/static text (LTEXT) should wrap like the default control. + format |= DT_WORDBREAK | DT_EDITCONTROL; + } + + DrawText(hdc, text, -1, &rc, format); + + if (hOldFont) + { + SelectObject(hdc, hOldFont); + } + + // Clean up any font we created on-demand + if( hCreatedFont ) + { + DeleteObject( hCreatedFont ); + } + }; + + if (IsDarkModeEnabled() || isOptionsHeader) + { + switch (uMsg) + { + case WM_ERASEBKGND: + { + HDC hdc = reinterpret_cast(wParam); + RECT rc; + GetClientRect(hWnd, &rc); + if( IsDarkModeEnabled() ) + { + FillRect(hdc, &rc, GetDarkModeBrush()); + } + else + { + FillRect(hdc, &rc, GetSysColorBrush( COLOR_BTNFACE )); + } + return TRUE; + } + + case WM_PAINT: + { + PAINTSTRUCT ps; + HDC hdc = BeginPaint(hWnd, &ps); + paintStaticText(hWnd, hdc); + EndPaint(hWnd, &ps); + return 0; + } + + case WM_PRINTCLIENT: + { + HDC hdc = reinterpret_cast(wParam); + paintStaticText(hWnd, hdc); + return 0; + } + + case WM_SETTEXT: + { + // Let the default handle the text change, then repaint + DefWindowProc(hWnd, uMsg, wParam, lParam); + InvalidateRect(hWnd, nullptr, TRUE); + return TRUE; + } + + case WM_ENABLE: + { + // Let base window proc handle enable state change, but avoid any subclass chain + // that might trigger themed drawing + LRESULT result = DefWindowProc(hWnd, uMsg, wParam, lParam); + // Force immediate repaint with our custom painting + InvalidateRect(hWnd, nullptr, TRUE); + UpdateWindow(hWnd); + return result; + } + + case WM_NCDESTROY: +#if _DEBUG + RemovePropW( hWnd, L"ZoomIt.VersionFontLogged" ); +#endif + RemoveWindowSubclass(hWnd, StaticTextSubclassProc, uIdSubclass); + break; + } + } + else + { + if (uMsg == WM_NCDESTROY) + { +#if _DEBUG + RemovePropW( hWnd, L"ZoomIt.VersionFontLogged" ); +#endif + RemoveWindowSubclass(hWnd, StaticTextSubclassProc, uIdSubclass); + } + } + return DefSubclassProc(hWnd, uMsg, wParam, lParam); +} + + + +//---------------------------------------------------------------------------- +// +// TabControlSubclassProc +// +// Subclass procedure for tab control to handle dark mode background +// +//---------------------------------------------------------------------------- +LRESULT CALLBACK TabControlSubclassProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam, UINT_PTR uIdSubclass, DWORD_PTR dwRefData) +{ + switch (uMsg) + { + case WM_ERASEBKGND: + if (IsDarkModeEnabled()) + { + HDC hdc = reinterpret_cast(wParam); + RECT rc; + GetClientRect(hWnd, &rc); + FillRect(hdc, &rc, GetDarkModeBrush()); + return TRUE; + } + break; + + case WM_PAINT: + if (IsDarkModeEnabled()) + { + PAINTSTRUCT ps; + HDC hdc = BeginPaint(hWnd, &ps); + + RECT rcClient; + GetClientRect(hWnd, &rcClient); + + // Fill entire background with dark color + FillRect(hdc, &rcClient, GetDarkModeBrush()); + + // Get the display area (content area below tabs) + RECT rcDisplay = rcClient; + TabCtrl_AdjustRect(hWnd, FALSE, &rcDisplay); + + // Debug output + TCHAR dbg[256]; + _stprintf_s(dbg, _T("TabCtrl: client=(%d,%d,%d,%d) display=(%d,%d,%d,%d)\n"), + rcClient.left, rcClient.top, rcClient.right, rcClient.bottom, + rcDisplay.left, rcDisplay.top, rcDisplay.right, rcDisplay.bottom); + OutputDebugString(dbg); + + // Draw grey border around the display area + HPEN hPen = CreatePen(PS_SOLID, 1, DarkMode::BorderColor); + HPEN hOldPen = static_cast(SelectObject(hdc, hPen)); + HBRUSH hOldBrush = static_cast(SelectObject(hdc, GetStockObject(NULL_BRUSH))); + + // Draw border at the edges of the display area (inset by 1 to be visible) + int left = rcDisplay.left; + int top = rcDisplay.top - 1; + int right = (rcDisplay.right < rcClient.right) ? rcDisplay.right : rcClient.right - 1; + int bottom = (rcDisplay.bottom < rcClient.bottom) ? rcDisplay.bottom : rcClient.bottom - 1; + + _stprintf_s(dbg, _T("TabCtrl border: left=%d top=%d right=%d bottom=%d\n"), left, top, right, bottom); + OutputDebugString(dbg); + + // Top line + MoveToEx(hdc, left, top, NULL); + LineTo(hdc, right, top); + // Right line + MoveToEx(hdc, right - 1, top, NULL); + LineTo(hdc, right - 1, bottom); + // Bottom line + MoveToEx(hdc, left, bottom - 1, NULL); + LineTo(hdc, right, bottom - 1); + // Left line + MoveToEx(hdc, left, top, NULL); + LineTo(hdc, left, bottom); + + // Draw each tab + int tabCount = TabCtrl_GetItemCount(hWnd); + int selectedTab = TabCtrl_GetCurSel(hWnd); + + // Get the font from the tab control + HFONT hFont = reinterpret_cast(SendMessage(hWnd, WM_GETFONT, 0, 0)); + HFONT hOldFont = hFont ? static_cast(SelectObject(hdc, hFont)) : nullptr; + + SetBkMode(hdc, TRANSPARENT); + + for (int i = 0; i < tabCount; i++) + { + RECT rcTab; + TabCtrl_GetItemRect(hWnd, i, &rcTab); + + bool isSelected = (i == selectedTab); + + // Fill tab background + FillRect(hdc, &rcTab, GetDarkModeBrush()); + + // Draw grey border around tab (left, top, right) + MoveToEx(hdc, rcTab.left, rcTab.bottom - 1, NULL); + LineTo(hdc, rcTab.left, rcTab.top); + LineTo(hdc, rcTab.right - 1, rcTab.top); + LineTo(hdc, rcTab.right - 1, rcTab.bottom); + + // For selected tab, erase the bottom border to merge with content + if (isSelected) + { + HPEN hBgPen = CreatePen(PS_SOLID, 1, DarkMode::BackgroundColor); + SelectObject(hdc, hBgPen); + MoveToEx(hdc, rcTab.left + 1, rcTab.bottom - 1, NULL); + LineTo(hdc, rcTab.right - 1, rcTab.bottom - 1); + SelectObject(hdc, hPen); + DeleteObject(hBgPen); + } + + // Get tab text + TCITEM tci = {}; + tci.mask = TCIF_TEXT; + TCHAR szText[128] = { 0 }; + tci.pszText = szText; + tci.cchTextMax = _countof(szText); + TabCtrl_GetItem(hWnd, i, &tci); + + // Draw text + SetTextColor(hdc, isSelected ? DarkMode::TextColor : DarkMode::DisabledTextColor); + RECT rcText = rcTab; + rcText.top += 4; + DrawText(hdc, szText, -1, &rcText, DT_CENTER | DT_VCENTER | DT_SINGLELINE); + + // Draw underline for selected tab + if (isSelected) + { + RECT rcUnderline = rcTab; + rcUnderline.top = rcUnderline.bottom - 2; + rcUnderline.left += 1; + rcUnderline.right -= 1; + HBRUSH hAccent = CreateSolidBrush(DarkMode::AccentColor); + FillRect(hdc, &rcUnderline, hAccent); + DeleteObject(hAccent); + } + } + + if (hOldFont) + SelectObject(hdc, hOldFont); + SelectObject(hdc, hOldBrush); + SelectObject(hdc, hOldPen); + DeleteObject(hPen); + + EndPaint(hWnd, &ps); + return 0; + } + break; + + case WM_NCDESTROY: + RemoveWindowSubclass(hWnd, TabControlSubclassProc, uIdSubclass); + break; + } + return DefSubclassProc(hWnd, uMsg, wParam, lParam); } //---------------------------------------------------------------------------- @@ -2083,18 +3560,172 @@ void UpdateDrawTabHeaderFont() INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message, WPARAM wParam, LPARAM lParam ) { + constexpr UINT WM_APPLY_HEADER_FONTS = WM_APP + 42; static HFONT hFontBold = nullptr; + static HFONT hFontVersion = nullptr; PNMLINK notify = nullptr; static int curTabSel = 0; static HWND hTabCtrl; static HWND hOpacity; static HWND hToggleKey; + static UINT currentDpi = DPI_BASELINE; + static RECT stableWindowRect{}; + static bool stableWindowRectValid = false; TCHAR text[32]; DWORD newToggleKey, newTimeout, newToggleMod, newBreakToggleKey, newDemoTypeToggleKey, newRecordToggleKey, newSnipToggleKey; DWORD newDrawToggleKey, newDrawToggleMod, newBreakToggleMod, newDemoTypeToggleMod, newRecordToggleMod, newSnipToggleMod; DWORD newLiveZoomToggleKey, newLiveZoomToggleMod; static std::vector> microphones; + auto CleanupFonts = [&]() + { + if( hFontBold ) + { + DeleteObject( hFontBold ); + hFontBold = nullptr; + } + if( hFontVersion ) + { + DeleteObject( hFontVersion ); + hFontVersion = nullptr; + } + }; + + auto UpdateVersionFont = [&]() + { + if( hFontVersion ) + { + DeleteObject( hFontVersion ); + hFontVersion = nullptr; + } + + HWND hVersion = GetDlgItem( hDlg, IDC_VERSION ); + if( !hVersion ) + { + return; + } + + // Prefer the control's current font (it may already be DPI-scaled). + HFONT hBaseFont = reinterpret_cast(SendMessage( hVersion, WM_GETFONT, 0, 0 )); + if( !hBaseFont ) + { + hBaseFont = reinterpret_cast(SendMessage( hDlg, WM_GETFONT, 0, 0 )); + } + if( !hBaseFont ) + { + hBaseFont = static_cast(GetStockObject( DEFAULT_GUI_FONT )); + } + + LOGFONT lf{}; + if( !GetObject( hBaseFont, sizeof( lf ), &lf ) ) + { + return; + } + + // Make the header version text title-sized using an explicit point size, + // scaled by the current DPI. + const UINT dpi = GetDpiForWindowHelper( hDlg ); + constexpr int kTitlePointSize = 22; + + lf.lfWeight = FW_BOLD; + lf.lfHeight = -MulDiv( kTitlePointSize, static_cast(dpi), 72 ); + hFontVersion = CreateFontIndirect( &lf ); + if( hFontVersion ) + { + SendMessage( hVersion, WM_SETFONT, reinterpret_cast(hFontVersion), TRUE ); + // Also store in a property so our subclass paint can reliably retrieve it. + SetPropW( hVersion, L"ZoomIt.HeaderFont", reinterpret_cast(hFontVersion) ); +#if _DEBUG + HFONT checkFont = static_cast(GetPropW( hVersion, L"ZoomIt.HeaderFont" )); + OutputDebug( L"SetPropW HeaderFont: hwnd=%p font=%p verify=%p\n", hVersion, hFontVersion, checkFont ); +#endif + } + + #if _DEBUG + OutputDebug(L"UpdateVersionFont: dpi=%u titlePt=%d lfHeight=%d font=%p\n", + dpi, kTitlePointSize, lf.lfHeight, hFontVersion ); + + { + HFONT currentFont = reinterpret_cast(SendMessage( hVersion, WM_GETFONT, 0, 0 )); + LOGFONT currentLf{}; + if( currentFont && GetObject( currentFont, sizeof( currentLf ), ¤tLf ) ) + { + OutputDebug( L"IDC_VERSION WM_GETFONT after set: font=%p lfHeight=%d lfWeight=%d\n", + currentFont, currentLf.lfHeight, currentLf.lfWeight ); + } + else + { + OutputDebug( L"IDC_VERSION WM_GETFONT after set: font=%p (no logfont)\n", currentFont ); + } + } + #endif + + // Resize the version control to fit the new font, and reflow the lines below if needed. + RECT rcVersion{}; + GetWindowRect( hVersion, &rcVersion ); + MapWindowPoints( nullptr, hDlg, reinterpret_cast(&rcVersion), 2 ); + const int oldVersionHeight = rcVersion.bottom - rcVersion.top; + + TCHAR versionText[128] = {}; + GetWindowText( hVersion, versionText, _countof( versionText ) ); + + RECT rcCalc{ 0, 0, 0, 0 }; + HDC hdc = GetDC( hVersion ); + if( hdc ) + { + HFONT oldFont = static_cast(SelectObject( hdc, hFontVersion ? hFontVersion : hBaseFont )); + DrawText( hdc, versionText, -1, &rcCalc, DT_CALCRECT | DT_SINGLELINE | DT_LEFT | DT_VCENTER ); + SelectObject( hdc, oldFont ); + ReleaseDC( hVersion, hdc ); + } + + // Keep within dialog client width. + RECT rcClient{}; + GetClientRect( hDlg, &rcClient ); + const int maxWidth = max( 0, rcClient.right - rcVersion.left - ScaleForDpi( 8, GetDpiForWindowHelper( hDlg ) ) ); + const int desiredWidth = min( maxWidth, (rcCalc.right - rcCalc.left) + ScaleForDpi( 6, GetDpiForWindowHelper( hDlg ) ) ); + const int desiredHeight = (rcCalc.bottom - rcCalc.top) + ScaleForDpi( 2, GetDpiForWindowHelper( hDlg ) ); + const int newVersionHeight = max( oldVersionHeight, desiredHeight ); + + SetWindowPos( hVersion, nullptr, + rcVersion.left, rcVersion.top, + max( 1, desiredWidth ), newVersionHeight, + SWP_NOZORDER | SWP_NOACTIVATE ); + +#if _DEBUG + { + RECT rcAfter{}; + GetClientRect( hVersion, &rcAfter ); + OutputDebug( L"UpdateVersionFont resize: desired=(%d,%d) oldH=%d newH=%d actual=(%d,%d)\n", + desiredWidth, desiredHeight, oldVersionHeight, newVersionHeight, + rcAfter.right - rcAfter.left, rcAfter.bottom - rcAfter.top ); + } +#endif + + InvalidateRect( hVersion, nullptr, TRUE ); + + const int deltaY = newVersionHeight - oldVersionHeight; + if( deltaY > 0 ) + { + const int headerIdsToShift[] = { IDC_COPYRIGHT, IDC_LINK }; + for( int i = 0; i < _countof( headerIdsToShift ); i++ ) + { + HWND hCtrl = GetDlgItem( hDlg, headerIdsToShift[i] ); + if( !hCtrl ) + { + continue; + } + RECT rc{}; + GetWindowRect( hCtrl, &rc ); + MapWindowPoints( nullptr, hDlg, reinterpret_cast(&rc), 2 ); + SetWindowPos( hCtrl, nullptr, + rc.left, rc.top + deltaY, + 0, 0, + SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE ); + } + } + }; + switch ( message ) { case WM_INITDIALOG: { @@ -2108,9 +3739,21 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message, } hWndOptions = hDlg; + // Set the dialog icon + { + HICON hIcon = LoadIcon( g_hInstance, L"APPICON" ); + if( hIcon ) + { + SendMessage( hDlg, WM_SETICON, ICON_BIG, reinterpret_cast(hIcon) ); + SendMessage( hDlg, WM_SETICON, ICON_SMALL, reinterpret_cast(hIcon) ); + } + } + SetForegroundWindow( hDlg ); SetActiveWindow( hDlg ); - SetWindowPos( hDlg, HWND_TOP, 0, 0, 0, 0, SWP_NOSIZE|SWP_NOMOVE|SWP_SHOWWINDOW ); + // Do not force-show the dialog here. DialogBox will show it after WM_INITDIALOG + // returns, and showing early causes visible layout churn while we add tabs, scale, + // and center the window. #if 1 // set version info TCHAR filePath[MAX_PATH]; @@ -2132,10 +3775,17 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message, #endif // Add tabs hTabCtrl = GetDlgItem( hDlg, IDC_TAB ); - OptionsAddTabs( hDlg, hTabCtrl ); - InitializeFonts( hDlg, &hFontBold ); - UpdateDrawTabHeaderFont(); + // Set owner-draw style for tab control when in dark mode + if (IsDarkModeEnabled()) + { + LONG_PTR style = GetWindowLongPtr(hTabCtrl, GWL_STYLE); + SetWindowLongPtr(hTabCtrl, GWL_STYLE, style | TCS_OWNERDRAWFIXED); + // Subclass the tab control for dark mode background painting + SetWindowSubclass(hTabCtrl, TabControlSubclassProc, 1, 0); + } + + OptionsAddTabs( hDlg, hTabCtrl ); // Configure options SendMessage( GetDlgItem( g_OptionsTabs[ZOOM_PAGE].hPage, IDC_HOTKEY), HKM_SETRULES, @@ -2184,6 +3834,9 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message, CheckDlgButton( g_OptionsTabs[BREAK_PAGE].hPage, IDC_CHECK_SHOW_EXPIRED, g_ShowExpiredTime ? BST_CHECKED : BST_UNCHECKED ); + CheckDlgButton( g_OptionsTabs[RECORD_PAGE].hPage, IDC_CAPTURE_SYSTEM_AUDIO, + g_CaptureSystemAudio ? BST_CHECKED: BST_UNCHECKED ); + CheckDlgButton( g_OptionsTabs[RECORD_PAGE].hPage, IDC_CAPTURE_AUDIO, g_CaptureAudio ? BST_CHECKED: BST_UNCHECKED ); @@ -2255,10 +3908,12 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message, } SendMessage( GetDlgItem( g_OptionsTabs[RECORD_PAGE].hPage, IDC_MICROPHONE ), CB_SETCURSEL, static_cast(selection), static_cast(0) ); - // Set initial state of microphone controls based on recording format + // Set initial state of audio controls based on recording format (GIF has no audio) bool isGifSelected = (g_RecordingFormat == RecordingFormat::GIF); - EnableWindow(GetDlgItem(g_OptionsTabs[RECORD_PAGE].hPage, IDC_MICROPHONE), !isGifSelected); + EnableWindow(GetDlgItem(g_OptionsTabs[RECORD_PAGE].hPage, IDC_CAPTURE_SYSTEM_AUDIO), !isGifSelected); EnableWindow(GetDlgItem(g_OptionsTabs[RECORD_PAGE].hPage, IDC_CAPTURE_AUDIO), !isGifSelected); + EnableWindow(GetDlgItem(g_OptionsTabs[RECORD_PAGE].hPage, IDC_MICROPHONE_LABEL), !isGifSelected); + EnableWindow(GetDlgItem(g_OptionsTabs[RECORD_PAGE].hPage, IDC_MICROPHONE), !isGifSelected); if( GetFileAttributes( g_DemoTypeFile ) == -1 ) { @@ -2272,11 +3927,113 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message, SendMessage( GetDlgItem( g_OptionsTabs[DEMOTYPE_PAGE].hPage, IDC_DEMOTYPE_SPEED_SLIDER ), TBM_SETPOS, true, g_DemoTypeSpeedSlider ); CheckDlgButton( g_OptionsTabs[DEMOTYPE_PAGE].hPage, IDC_DEMOTYPE_USER_DRIVEN, g_DemoTypeUserDriven ? BST_CHECKED: BST_UNCHECKED ); + // Apply DPI scaling to the main dialog and to controls inside tab pages. + // Note: Scaling the main dialog only scales its direct children (including the + // tab page windows), but NOT the controls contained within the tab pages. + // So we scale each tab page's child controls separately. + currentDpi = GetDpiForWindowHelper( hDlg ); + if( currentDpi != DPI_BASELINE ) + { + ScaleDialogForDpi( hDlg, currentDpi, DPI_BASELINE ); + + for( int i = 0; i < sizeof( g_OptionsTabs ) / sizeof( g_OptionsTabs[0] ); i++ ) + { + if( g_OptionsTabs[i].hPage ) + { + ScaleChildControlsForDpi( g_OptionsTabs[i].hPage, currentDpi, DPI_BASELINE ); + } + } + } + // Always reposition tab pages to fit the tab control (whether scaled or not) + RepositionTabPages( hTabCtrl ); + + // Initialize DPI-aware fonts after scaling so text sizing is correct. + InitializeFonts( hDlg, &hFontBold ); + UpdateDrawTabHeaderFont(); + UpdateVersionFont(); + + // Always render the header labels using our static text subclass (even in light mode) + // so the larger title font is honored. + if( HWND hVersion = GetDlgItem( hDlg, IDC_VERSION ) ) + { + SetWindowSubclass( hVersion, StaticTextSubclassProc, 55, 0 ); + } + if( HWND hCopyright = GetDlgItem( hDlg, IDC_COPYRIGHT ) ) + { + SetWindowSubclass( hCopyright, StaticTextSubclassProc, 56, 0 ); + } + + // Apply dark mode to the dialog and all tab pages + ApplyDarkModeToDialog( hDlg ); + for( int i = 0; i < sizeof( g_OptionsTabs ) / sizeof( g_OptionsTabs[0] ); i++ ) + { + if( g_OptionsTabs[i].hPage ) + { + ApplyDarkModeToDialog( g_OptionsTabs[i].hPage ); + } + } + UnregisterAllHotkeys(GetParent( hDlg )); + + // Center dialog on screen, clamping to fit if it's too large for the work area + { + RECT rcDlg; + GetWindowRect(hDlg, &rcDlg); + int dlgWidth = rcDlg.right - rcDlg.left; + int dlgHeight = rcDlg.bottom - rcDlg.top; + + // Get the monitor where the cursor is + POINT pt; + GetCursorPos(&pt); + HMONITOR hMon = MonitorFromPoint(pt, MONITOR_DEFAULTTONEAREST); + MONITORINFO mi = { sizeof(mi) }; + GetMonitorInfo(hMon, &mi); + + // Calculate available work area size + const int workWidth = mi.rcWork.right - mi.rcWork.left; + const int workHeight = mi.rcWork.bottom - mi.rcWork.top; + + // Clamp dialog size to fit within work area (with a small margin) + constexpr int kMargin = 8; + if (dlgWidth > workWidth - kMargin * 2) + { + dlgWidth = workWidth - kMargin * 2; + } + if (dlgHeight > workHeight - kMargin * 2) + { + dlgHeight = workHeight - kMargin * 2; + } + + // Apply clamped size if it changed + if (dlgWidth != (rcDlg.right - rcDlg.left) || dlgHeight != (rcDlg.bottom - rcDlg.top)) + { + SetWindowPos(hDlg, nullptr, 0, 0, dlgWidth, dlgHeight, SWP_NOMOVE | SWP_NOZORDER); + } + + int x = mi.rcWork.left + (workWidth - dlgWidth) / 2; + int y = mi.rcWork.top + (workHeight - dlgHeight) / 2; + SetWindowPos(hDlg, nullptr, x, y, 0, 0, SWP_NOSIZE | SWP_NOZORDER); + } + + // Capture a stable window size so per-monitor DPI changes won't resize/reflow the dialog. + GetWindowRect(hDlg, &stableWindowRect); + stableWindowRectValid = true; + PostMessage( hDlg, WM_USER, 0, 0 ); - return TRUE; + // Reapply header fonts once the dialog has finished any late initialization. + PostMessage( hDlg, WM_APPLY_HEADER_FONTS, 0, 0 ); + + // Set focus to the tab control instead of the first hotkey control + SetFocus( hTabCtrl ); + return FALSE; } + case WM_APPLY_HEADER_FONTS: + InitializeFonts( hDlg, &hFontBold ); + UpdateDrawTabHeaderFont(); + UpdateVersionFont(); + return TRUE; + case WM_USER+100: BringWindowToTop( hDlg ); SetFocus( hDlg ); @@ -2284,24 +4041,168 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message, return TRUE; case WM_DPICHANGED: - InitializeFonts( hDlg, &hFontBold ); - UpdateDrawTabHeaderFont(); - break; + { + // Requirement: keep the Options dialog stable while it is open. + // Windows may already have resized the window by the time this arrives, + // so explicitly restore the previous size (but allow the suggested top-left). - case WM_CTLCOLORSTATIC: - if( reinterpret_cast(lParam) == GetDlgItem( hDlg, IDC_TITLE ) || - reinterpret_cast(lParam) == GetDlgItem(hDlg, IDC_DRAWING) || - reinterpret_cast(lParam) == GetDlgItem(hDlg, IDC_ZOOM) || - reinterpret_cast(lParam) == GetDlgItem(hDlg, IDC_BREAK) || - reinterpret_cast(lParam) == GetDlgItem( hDlg, IDC_TYPE )) { + RECT* suggested = reinterpret_cast(lParam); + if (stableWindowRectValid && suggested) + { + const int stableW = stableWindowRect.right - stableWindowRect.left; + const int stableH = stableWindowRect.bottom - stableWindowRect.top; + SetWindowPos(hDlg, nullptr, + suggested->left, + suggested->top, + stableW, + stableH, + SWP_NOZORDER | SWP_NOACTIVATE); + } + return TRUE; + } - HDC hdc = reinterpret_cast(wParam); - SetBkMode( hdc, TRANSPARENT ); - SelectObject( hdc, hFontBold ); - return PtrToLong(GetSysColorBrush( COLOR_BTNFACE )); + case WM_ERASEBKGND: + if (IsDarkModeEnabled()) + { + HDC hdc = reinterpret_cast(wParam); + RECT rc; + GetClientRect(hDlg, &rc); + FillRect(hdc, &rc, GetDarkModeBrush()); + return TRUE; } break; + case WM_CTLCOLORDLG: + case WM_CTLCOLORSTATIC: + case WM_CTLCOLORBTN: + case WM_CTLCOLOREDIT: + case WM_CTLCOLORLISTBOX: + { + HDC hdc = reinterpret_cast(wParam); + HWND hCtrl = reinterpret_cast(lParam); + + // Always force the Options header title to use the large version font. + // Note: We must also return a brush in light mode + // dialog proc may ignore our HDC changes. + if( message == WM_CTLCOLORSTATIC && hCtrl == GetDlgItem( hDlg, IDC_VERSION ) && hFontVersion ) + { + SetBkMode( hdc, TRANSPARENT ); + SelectObject( hdc, hFontVersion ); + +#if _DEBUG + OutputDebug( L"WM_CTLCOLORSTATIC IDC_VERSION: dark=%d font=%p\n", IsDarkModeEnabled() ? 1 : 0, hFontVersion ); +#endif + + if( !IsDarkModeEnabled() ) + { + // Light mode: explicitly return the dialog background brush. + return reinterpret_cast(GetSysColorBrush( COLOR_BTNFACE )); + } + } + + // Handle dark mode colors + HBRUSH hBrush = HandleDarkModeCtlColor(hdc, hCtrl, message); + if (hBrush) + { + // Ensure the header version text uses the title font in dark mode. + if( message == WM_CTLCOLORSTATIC && hCtrl == GetDlgItem( hDlg, IDC_VERSION ) && hFontVersion ) + { + SelectObject( hdc, hFontVersion ); + } + + // For bold title controls, also set the bold font + if (message == WM_CTLCOLORSTATIC && + (hCtrl == GetDlgItem(hDlg, IDC_TITLE) || + hCtrl == GetDlgItem(hDlg, IDC_DRAWING) || + hCtrl == GetDlgItem(hDlg, IDC_ZOOM) || + hCtrl == GetDlgItem(hDlg, IDC_BREAK) || + hCtrl == GetDlgItem(hDlg, IDC_TYPE))) + { + SelectObject(hdc, hFontBold); + } + return reinterpret_cast(hBrush); + } + + // Light mode handling for bold title controls + if (message == WM_CTLCOLORSTATIC && + (hCtrl == GetDlgItem(hDlg, IDC_TITLE) || + hCtrl == GetDlgItem(hDlg, IDC_DRAWING) || + hCtrl == GetDlgItem(hDlg, IDC_ZOOM) || + hCtrl == GetDlgItem(hDlg, IDC_BREAK) || + hCtrl == GetDlgItem(hDlg, IDC_TYPE))) + { + SetBkMode(hdc, TRANSPARENT); + SelectObject(hdc, hFontBold); + return reinterpret_cast(GetSysColorBrush(COLOR_BTNFACE)); + } + break; + } + + case WM_SETTINGCHANGE: + // Handle theme change (dark/light mode toggle) + if (lParam && (wcscmp(reinterpret_cast(lParam), L"ImmersiveColorSet") == 0)) + { + RefreshDarkModeState(); + ApplyDarkModeToDialog(hDlg); + for (int i = 0; i < sizeof(g_OptionsTabs) / sizeof(g_OptionsTabs[0]); i++) + { + if (g_OptionsTabs[i].hPage) + { + ApplyDarkModeToDialog(g_OptionsTabs[i].hPage); + } + } + InvalidateRect(hDlg, nullptr, TRUE); + for (int i = 0; i < sizeof(g_OptionsTabs) / sizeof(g_OptionsTabs[0]); i++) + { + if (g_OptionsTabs[i].hPage) + { + InvalidateRect(g_OptionsTabs[i].hPage, nullptr, TRUE); + } + } + } + break; + + case WM_DRAWITEM: + { + // Handle owner-draw for tab control in dark mode + DRAWITEMSTRUCT* pDIS = reinterpret_cast(lParam); + if (pDIS->CtlID == IDC_TAB && IsDarkModeEnabled()) + { + // Fill tab background + HBRUSH hBrush = GetDarkModeBrush(); + FillRect(pDIS->hDC, &pDIS->rcItem, hBrush); + + // Get tab text + TCITEM tci = {}; + tci.mask = TCIF_TEXT; + TCHAR szText[128] = { 0 }; + tci.pszText = szText; + tci.cchTextMax = _countof(szText); + TabCtrl_GetItem(hTabCtrl, pDIS->itemID, &tci); + + // Draw text + SetBkMode(pDIS->hDC, TRANSPARENT); + bool isSelected = (pDIS->itemState & ODS_SELECTED) != 0; + SetTextColor(pDIS->hDC, isSelected ? DarkMode::TextColor : DarkMode::DisabledTextColor); + + // Draw underline for selected tab + if (isSelected) + { + RECT rcUnderline = pDIS->rcItem; + rcUnderline.top = rcUnderline.bottom - 2; + HBRUSH hAccent = CreateSolidBrush(DarkMode::AccentColor); + FillRect(pDIS->hDC, &rcUnderline, hAccent); + DeleteObject(hAccent); + } + + RECT rcText = pDIS->rcItem; + rcText.top += 4; + DrawText(pDIS->hDC, szText, -1, &rcText, DT_CENTER | DT_VCENTER | DT_SINGLELINE); + return TRUE; + } + break; + } + case WM_NOTIFY: notify = reinterpret_cast(lParam); if( notify->hdr.idFrom == IDC_LINK ) @@ -2357,6 +4258,7 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message, g_DemoTypeSpeedSlider = static_cast(SendMessage( GetDlgItem( g_OptionsTabs[DEMOTYPE_PAGE].hPage, IDC_DEMOTYPE_SPEED_SLIDER ), TBM_GETPOS, 0, 0 )); g_ShowExpiredTime = IsDlgButtonChecked( g_OptionsTabs[BREAK_PAGE].hPage, IDC_CHECK_SHOW_EXPIRED ) == BST_CHECKED; + g_CaptureSystemAudio = IsDlgButtonChecked(g_OptionsTabs[RECORD_PAGE].hPage, IDC_CAPTURE_SYSTEM_AUDIO) == BST_CHECKED; g_CaptureAudio = IsDlgButtonChecked(g_OptionsTabs[RECORD_PAGE].hPage, IDC_CAPTURE_AUDIO) == BST_CHECKED; GetDlgItemText( g_OptionsTabs[BREAK_PAGE].hPage, IDC_TIMER, text, 3 ); text[2] = 0; @@ -2450,6 +4352,7 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message, EnableDisableTrayIcon( GetParent( hDlg ), g_ShowTrayIcon ); hWndOptions = NULL; + CleanupFonts(); EndDialog( hDlg, 0 ); return TRUE; } @@ -2459,6 +4362,7 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message, case IDCANCEL: RegisterAllHotkeys(GetParent(hDlg)); hWndOptions = NULL; + CleanupFonts(); EndDialog( hDlg, 0 ); return TRUE; } @@ -2467,6 +4371,7 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message, case WM_CLOSE: hWndOptions = NULL; RegisterAllHotkeys(GetParent(hDlg)); + CleanupFonts(); EndDialog( hDlg, 0 ); return TRUE; @@ -3344,7 +5249,6 @@ inline auto PrepareStagingTexture(winrt::com_ptr const& device, winrt::com_ptr const& texture) { // If our texture is already set up for staging, then use it. - // Otherwise, create a staging texture. D3D11_TEXTURE2D_DESC desc = {}; texture->GetDesc(&desc); if (desc.Usage == D3D11_USAGE_STAGING && desc.CPUAccessFlags & D3D11_CPU_ACCESS_READ) @@ -3499,20 +5403,24 @@ inline auto CopyBytesFromTexture(winrt::com_ptr const& texture, //---------------------------------------------------------------------------- void StopRecording() { + OutputDebugStringW(L"[Recording] StopRecording called\n"); if( g_RecordToggle == TRUE ) { + OutputDebugStringW(L"[Recording] g_RecordToggle was TRUE, stopping...\n"); g_SelectRectangle.Stop(); if ( g_RecordingSession != nullptr ) { + OutputDebugStringW(L"[Recording] Closing VideoRecordingSession\n"); g_RecordingSession->Close(); - g_RecordingSession = nullptr; + // NOTE: Do NOT null the session here - let the coroutine finish first } if ( g_GifRecordingSession != nullptr ) { + OutputDebugStringW(L"[Recording] Closing GifRecordingSession\n"); g_GifRecordingSession->Close(); - g_GifRecordingSession = nullptr; + // NOTE: Do NOT null the session here - let the coroutine finish first } g_RecordToggle = FALSE; @@ -3532,6 +5440,55 @@ void StopRecording() } +//---------------------------------------------------------------------------- +// +// GetUniqueFilename +// +// Returns a unique filename by checking for existing files and adding (1), (2), etc. +// suffixes as needed. Uses the folder from lastSavePath if available +// +//---------------------------------------------------------------------------- +auto GetUniqueFilename(const std::wstring& lastSavePath, const wchar_t* defaultFilename, REFKNOWNFOLDERID defaultFolderId) +{ + // Get the folder where the file will be saved + std::filesystem::path saveFolder; + if (!lastSavePath.empty()) + { + // Use folder from last save location + saveFolder = std::filesystem::path(lastSavePath).parent_path(); + } + + if (saveFolder.empty()) + { + // Default to specified known folder + wil::unique_cotaskmem_string folderPath; + if (SUCCEEDED(SHGetKnownFolderPath(defaultFolderId, KF_FLAG_DEFAULT, nullptr, folderPath.put()))) + { + saveFolder = folderPath.get(); + } + } + + // Build base name and extension + std::filesystem::path defaultPath = defaultFilename; + auto base = defaultPath.stem().wstring(); + auto ext = defaultPath.extension().wstring(); + + // Check for existing files and find unique name + std::wstring candidateName = base + ext; + std::filesystem::path checkPath = saveFolder / candidateName; + + int index = 1; + std::error_code ec; + while (std::filesystem::exists(checkPath, ec)) + { + candidateName = base + L" (" + std::to_wstring(index) + L")" + ext; + checkPath = saveFolder / candidateName; + index++; + } + + return candidateName; +} + //---------------------------------------------------------------------------- // // GetUniqueRecordingFilename @@ -3544,28 +5501,16 @@ void StopRecording() //---------------------------------------------------------------------------- auto GetUniqueRecordingFilename() { - std::filesystem::path path; + const wchar_t* defaultFile = (g_RecordingFormat == RecordingFormat::GIF) + ? DEFAULT_GIF_RECORDING_FILE + : DEFAULT_RECORDING_FILE; - if (g_RecordingFormat == RecordingFormat::GIF) - { - path = g_RecordingSaveLocationGIF; - } - else - { - path = g_RecordingSaveLocation; - } + return GetUniqueFilename(g_RecordingSaveLocation, defaultFile, FOLDERID_Videos); +} - // Chop off index if it's there - auto base = std::regex_replace( path.stem().wstring(), std::wregex( L" [(][0-9]+[)]$" ), L"" ); - path.replace_filename( base + path.extension().wstring() ); - - for( int index = 1; std::filesystem::exists( path ); index++ ) - { - - // File exists, so increment number to avoid collision - path.replace_filename( base + L" (" + std::to_wstring(index) + L')' + path.extension().wstring() ); - } - return path.stem().wstring() + path.extension().wstring(); +auto GetUniqueScreenshotFilename() +{ + return GetUniqueFilename(g_ScreenshotSaveLocation, DEFAULT_SCREENSHOT_FILE, FOLDERID_Pictures); } //---------------------------------------------------------------------------- @@ -3577,6 +5522,9 @@ auto GetUniqueRecordingFilename() //---------------------------------------------------------------------------- winrt::fire_and_forget StartRecordingAsync( HWND hWnd, LPRECT rcCrop, HWND hWndRecord ) try { + // Capture the UI thread context so we can resume on it for the save dialog + winrt::apartment_context uiThread; + auto tempFolderPath = std::filesystem::temp_directory_path().wstring(); auto tempFolder = co_await winrt::StorageFolder::GetFolderFromPathAsync( tempFolderPath ); auto appFolder = co_await tempFolder.CreateFolderAsync( L"ZoomIt", winrt::CreationCollisionOption::OpenIfExists ); @@ -3609,6 +5557,9 @@ winrt::fire_and_forget StartRecordingAsync( HWND hWnd, LPRECT rcCrop, HWND hWndR // Create the appropriate recording session based on format OutputDebugStringW((L"Starting recording session. Framerate: " + std::to_wstring(g_RecordFrameRate) + L" scaling: " + std::to_wstring(g_RecordScaling) + L" Format: " + (g_RecordingFormat == RecordingFormat::GIF ? L"GIF" : L"MP4") + L"\n").c_str()); + bool recordingStarted = false; + HRESULT captureStatus = S_OK; + if (g_RecordingFormat == RecordingFormat::GIF) { g_GifRecordingSession = GifRecordingSession::Create( @@ -3618,10 +5569,37 @@ winrt::fire_and_forget StartRecordingAsync( HWND hWnd, LPRECT rcCrop, HWND hWndR g_RecordFrameRate, stream ); + recordingStarted = (g_GifRecordingSession != nullptr); + if( g_hWndLiveZoom != NULL ) g_GifRecordingSession->EnableCursorCapture( false ); - co_await g_GifRecordingSession->StartAsync(); + if (recordingStarted) + { + try + { + co_await g_GifRecordingSession->StartAsync(); + } + catch (const winrt::hresult_error& error) + { + captureStatus = error.code(); + OutputDebugStringW((L"Recording session failed: " + error.message() + L"\n").c_str()); + } + } + + // If no frames were captured, behave as if the hotkey was never pressed. + if (recordingStarted && g_GifRecordingSession && !g_GifRecordingSession->HasCapturedFrames()) + { + if (stream) + { + stream.Close(); + stream = nullptr; + } + try { co_await file.DeleteAsync(); } catch (...) {} + g_RecordingSession = nullptr; + g_GifRecordingSession = nullptr; + co_return; + } } else { @@ -3631,16 +5609,61 @@ winrt::fire_and_forget StartRecordingAsync( HWND hWnd, LPRECT rcCrop, HWND hWndR *rcCrop, g_RecordFrameRate, g_CaptureAudio, + g_CaptureSystemAudio, stream ); + recordingStarted = (g_RecordingSession != nullptr); + if( g_hWndLiveZoom != NULL ) g_RecordingSession->EnableCursorCapture( false ); - co_await g_RecordingSession->StartAsync(); + if (recordingStarted) + { + try + { + co_await g_RecordingSession->StartAsync(); + } + catch (const winrt::hresult_error& error) + { + captureStatus = error.code(); + OutputDebugStringW((L"Recording session failed: " + error.message() + L"\n").c_str()); + } + } + + // If no frames were captured, behave as if the hotkey was never pressed. + if (recordingStarted && g_RecordingSession && !g_RecordingSession->HasCapturedVideoFrames()) + { + if (stream) + { + stream.Close(); + stream = nullptr; + } + try { co_await file.DeleteAsync(); } catch (...) {} + g_RecordingSession = nullptr; + g_GifRecordingSession = nullptr; + co_return; + } } - // Check if recording was aborted - if( g_RecordingSession == nullptr && g_GifRecordingSession == nullptr ) { + // If we never created a session, bail and clean up the temp file silently + if( !recordingStarted ) { + + if (stream) { + stream.Close(); + stream = nullptr; + } + try { co_await file.DeleteAsync(); } catch (...) {} + co_return; + } + + // Recording completed (closed via hotkey or item close). Proceed to save/trim workflow. + OutputDebugStringW(L"[Recording] StartAsync completed, entering save workflow\n"); + + // Resume on the UI thread for the save dialog + co_await uiThread; + OutputDebugStringW(L"[Recording] Resumed on UI thread\n"); + + { g_bSaveInProgress = true; @@ -3649,106 +5672,149 @@ winrt::fire_and_forget StartRecordingAsync( HWND hWnd, LPRECT rcCrop, HWND hWndR winrt::StorageFile destFile = nullptr; HRESULT hr = S_OK; try { - auto saveDialog = wil::CoCreateInstance( CLSID_FileSaveDialog ); - FILEOPENDIALOGOPTIONS options; - if( SUCCEEDED( saveDialog->GetOptions( &options ) ) ) - saveDialog->SetOptions( options | FOS_FORCEFILESYSTEM ); - wil::com_ptr videosItem; - if( SUCCEEDED ( SHGetKnownFolderItem( FOLDERID_Videos, KF_FLAG_DEFAULT, nullptr, IID_IShellItem, (void**) videosItem.put() ) ) ) - saveDialog->SetDefaultFolder( videosItem.get() ); + // Show trim dialog option and save dialog + std::wstring trimmedFilePath; + auto suggestedName = GetUniqueRecordingFilename(); + auto finalPath = VideoRecordingSession::ShowSaveDialogWithTrim( + hWnd, + suggestedName, + std::wstring{ file.Path() }, + trimmedFilePath + ); - // Set file type based on the recording format - if (g_RecordingFormat == RecordingFormat::GIF) + if (!finalPath.empty()) { - saveDialog->SetDefaultExtension( L".gif" ); - COMDLG_FILTERSPEC fileTypes[] = { - { L"GIF Animation", L"*.gif" } - }; - saveDialog->SetFileTypes( _countof( fileTypes ), fileTypes ); + auto path = std::filesystem::path(finalPath); + winrt::StorageFolder folder{ co_await winrt::StorageFolder::GetFolderFromPathAsync(path.parent_path().c_str()) }; + destFile = co_await folder.CreateFileAsync(path.filename().c_str(), winrt::CreationCollisionOption::ReplaceExisting); + + // If user trimmed, use the trimmed file + winrt::StorageFile sourceFile = file; + if (!trimmedFilePath.empty()) + { + sourceFile = co_await winrt::StorageFile::GetFileFromPathAsync(trimmedFilePath); + } + + // Move the chosen source into the user-selected destination + co_await sourceFile.MoveAndReplaceAsync(destFile); + + // If we moved a trimmed copy, clean up the original temp capture file + if (sourceFile != file) + { + try { co_await file.DeleteAsync(); } catch (...) {} + } + + // Use finalPath directly - destFile.Path() may be stale after MoveAndReplaceAsync + g_RecordingSaveLocation = finalPath; + // Update the registry buffer and save to persist across app restarts + wcsncpy_s(g_RecordingSaveLocationBuffer, g_RecordingSaveLocation.c_str(), _TRUNCATE); + reg.WriteRegSettings(RegSettings); + SaveToClipboard(g_RecordingSaveLocation.c_str(), hWnd); } else { - saveDialog->SetDefaultExtension( L".mp4" ); - COMDLG_FILTERSPEC fileTypes[] = { - { L"MP4 Video", L"*.mp4" } - }; - saveDialog->SetFileTypes( _countof( fileTypes ), fileTypes ); + // User cancelled + hr = HRESULT_FROM_WIN32(ERROR_CANCELLED); } - // Peek the folder Windows has chosen to display - static std::filesystem::path lastSaveFolder; - wil::unique_cotaskmem_string chosenFolderPath; - wil::com_ptr currentSelectedFolder; - bool bFolderChanged = false; - if (SUCCEEDED(saveDialog->GetFolder(currentSelectedFolder.put()))) - { - if (SUCCEEDED(currentSelectedFolder->GetDisplayName(SIGDN_FILESYSPATH, chosenFolderPath.put()))) - { - if (lastSaveFolder != chosenFolderPath.get()) - { - lastSaveFolder = chosenFolderPath.get() ? chosenFolderPath.get() : std::filesystem::path{}; - bFolderChanged = true; - } - } - } + //auto saveDialog = wil::CoCreateInstance( CLSID_FileSaveDialog ); + //FILEOPENDIALOGOPTIONS options; + //if( SUCCEEDED( saveDialog->GetOptions( &options ) ) ) + // saveDialog->SetOptions( options | FOS_FORCEFILESYSTEM ); + //wil::com_ptr videosItem; + //if( SUCCEEDED ( SHGetKnownFolderItem( FOLDERID_Videos, KF_FLAG_DEFAULT, nullptr, IID_IShellItem, (void**) videosItem.put() ) ) ) + // saveDialog->SetDefaultFolder( videosItem.get() ); - if( (g_RecordingFormat == RecordingFormat::GIF && g_RecordingSaveLocationGIF.size() == 0) || (g_RecordingFormat == RecordingFormat::MP4 && g_RecordingSaveLocation.size() == 0) || (bFolderChanged)) { + //// Set file type based on the recording format + //if (g_RecordingFormat == RecordingFormat::GIF) + //{ + // saveDialog->SetDefaultExtension( L".gif" ); + // COMDLG_FILTERSPEC fileTypes[] = { + // { L"GIF Animation", L"*.gif" } + // }; + // saveDialog->SetFileTypes( _countof( fileTypes ), fileTypes ); + //} + //else + //{ + // saveDialog->SetDefaultExtension( L".mp4" ); + // COMDLG_FILTERSPEC fileTypes[] = { + // { L"MP4 Video", L"*.mp4" } + // }; + // saveDialog->SetFileTypes( _countof( fileTypes ), fileTypes ); + //} - wil::com_ptr shellItem; - wil::unique_cotaskmem_string folderPath; - if (SUCCEEDED(saveDialog->GetFolder(shellItem.put())) && SUCCEEDED(shellItem->GetDisplayName(SIGDN_FILESYSPATH, folderPath.put()))) { - if (g_RecordingFormat == RecordingFormat::GIF) { - g_RecordingSaveLocationGIF = folderPath.get(); - std::filesystem::path currentPath{ g_RecordingSaveLocationGIF }; - g_RecordingSaveLocationGIF = currentPath / DEFAULT_GIF_RECORDING_FILE; - } - else { - g_RecordingSaveLocation = folderPath.get(); - if (g_RecordingFormat == RecordingFormat::MP4) { - std::filesystem::path currentPath{ g_RecordingSaveLocation }; - g_RecordingSaveLocation = currentPath / DEFAULT_RECORDING_FILE; - } - } - } - } + //// Peek the folder Windows has chosen to display + //static std::filesystem::path lastSaveFolder; + //wil::unique_cotaskmem_string chosenFolderPath; + //wil::com_ptr currentSelectedFolder; + //bool bFolderChanged = false; + //if (SUCCEEDED(saveDialog->GetFolder(currentSelectedFolder.put()))) + //{ + // if (SUCCEEDED(currentSelectedFolder->GetDisplayName(SIGDN_FILESYSPATH, chosenFolderPath.put()))) + // { + // if (lastSaveFolder != chosenFolderPath.get()) + // { + // lastSaveFolder = chosenFolderPath.get() ? chosenFolderPath.get() : std::filesystem::path{}; + // bFolderChanged = true; + // } + // } + //} - // Always use appropriate default filename based on current format - auto suggestedName = GetUniqueRecordingFilename(); - saveDialog->SetFileName( suggestedName.c_str() ); + //if( (g_RecordingFormat == RecordingFormat::GIF && g_RecordingSaveLocationGIF.size() == 0) || (g_RecordingFormat == RecordingFormat::MP4 && g_RecordingSaveLocation.size() == 0) || (bFolderChanged)) { - THROW_IF_FAILED( saveDialog->Show( hWnd ) ); - wil::com_ptr shellItem; - THROW_IF_FAILED(saveDialog->GetResult(shellItem.put())); - wil::unique_cotaskmem_string filePath; - THROW_IF_FAILED(shellItem->GetDisplayName(SIGDN_FILESYSPATH, filePath.put())); - auto path = std::filesystem::path( filePath.get() ); + // wil::com_ptr shellItem; + // wil::unique_cotaskmem_string folderPath; + // if (SUCCEEDED(saveDialog->GetFolder(shellItem.put())) && SUCCEEDED(shellItem->GetDisplayName(SIGDN_FILESYSPATH, folderPath.put()))) { + // if (g_RecordingFormat == RecordingFormat::GIF) { + // g_RecordingSaveLocationGIF = folderPath.get(); + // std::filesystem::path currentPath{ g_RecordingSaveLocationGIF }; + // g_RecordingSaveLocationGIF = currentPath / DEFAULT_GIF_RECORDING_FILE; + // } + // else { + // g_RecordingSaveLocation = folderPath.get(); + // if (g_RecordingFormat == RecordingFormat::MP4) { + // std::filesystem::path currentPath{ g_RecordingSaveLocation }; + // g_RecordingSaveLocation = currentPath / DEFAULT_RECORDING_FILE; + // } + // } + // } + //} - winrt::StorageFolder folder{ co_await winrt::StorageFolder::GetFolderFromPathAsync( path.parent_path().c_str() ) }; - destFile = co_await folder.CreateFileAsync( path.filename().c_str(), winrt::CreationCollisionOption::ReplaceExisting ); + //// Always use appropriate default filename based on current format + //auto suggestedName = GetUniqueRecordingFilename(); + //saveDialog->SetFileName( suggestedName.c_str() ); + + //THROW_IF_FAILED( saveDialog->Show( hWnd ) ); + //wil::com_ptr shellItem; + //THROW_IF_FAILED(saveDialog->GetResult(shellItem.put())); + //wil::unique_cotaskmem_string filePath; + //THROW_IF_FAILED(shellItem->GetDisplayName(SIGDN_FILESYSPATH, filePath.put())); + //auto path = std::filesystem::path( filePath.get() ); + + //winrt::StorageFolder folder{ co_await winrt::StorageFolder::GetFolderFromPathAsync( path.parent_path().c_str() ) }; + //destFile = co_await folder.CreateFileAsync( path.filename().c_str(), winrt::CreationCollisionOption::ReplaceExisting ); } catch( const wil::ResultException& error ) { - + OutputDebugStringW((L"[Recording] wil exception: hr=0x" + std::to_wstring(error.GetErrorCode()) + L"\n").c_str()); hr = error.GetErrorCode(); } + catch( const std::exception& ex ) { + OutputDebugStringA("[Recording] std::exception: "); + OutputDebugStringA(ex.what()); + OutputDebugStringA("\n"); + hr = E_FAIL; + } + catch( ... ) { + OutputDebugStringW(L"[Recording] Unknown exception in save workflow\n"); + hr = E_FAIL; + } if( destFile == nullptr ) { if (stream) { stream.Close(); stream = nullptr; } - co_await file.DeleteAsync(); - } - else { - - co_await file.MoveAndReplaceAsync(destFile); - if (g_RecordingFormat == RecordingFormat::GIF) { - g_RecordingSaveLocationGIF = file.Path(); - SaveToClipboard(g_RecordingSaveLocationGIF.c_str(), hWnd); - } - else { - g_RecordingSaveLocation = file.Path(); - SaveToClipboard(g_RecordingSaveLocation.c_str(), hWnd); - } + try { co_await file.DeleteAsync(); } catch (...) {} } g_bSaveInProgress = false; @@ -3759,18 +5825,19 @@ winrt::fire_and_forget StartRecordingAsync( HWND hWnd, LPRECT rcCrop, HWND hWndR if( FAILED( hr ) ) throw winrt::hresult_error( hr ); } - else { - if (stream) { - stream.Close(); - stream = nullptr; - } - co_await file.DeleteAsync(); - g_RecordingSession = nullptr; - g_GifRecordingSession = nullptr; + // Ensure globals are reset after the save/cleanup path completes + if (stream) { + stream.Close(); + stream = nullptr; } + g_RecordingSession = nullptr; + g_GifRecordingSession = nullptr; } catch( const winrt::hresult_error& error ) { + // Reset the save-in-progress flag so that hotkeys are not blocked after an error or cancellation + g_bSaveInProgress = false; + PostMessage( g_hWndMain, WM_USER_STOP_RECORDING, 0, 0 ); // Suppress the error from canceling the save dialog @@ -3953,7 +6020,6 @@ LRESULT APIENTRY MainWndProc( HWND hWndRecord; int x, y, delta; HMENU hPopupMenu; - OPENFILENAME openFileName; static TCHAR filePath[MAX_PATH] = {L"zoomit"}; NOTIFYICONDATA tNotifyIconData; static DWORD64 g_TelescopingZoomLastTick = 0ull; @@ -4013,6 +6079,13 @@ LRESULT APIENTRY MainWndProc( if( wParam == 2 && zoomLevel == 1 ) { g_Zoomed = FALSE; + + // Unregister Ctrl+C and Ctrl+S hotkeys when exiting static zoom + UnregisterHotKey( hWnd, COPY_IMAGE_HOTKEY ); + UnregisterHotKey( hWnd, COPY_CROP_HOTKEY ); + UnregisterHotKey( hWnd, SAVE_IMAGE_HOTKEY ); + UnregisterHotKey( hWnd, SAVE_CROP_HOTKEY ); + if( g_ZoomOnLiveZoom ) { GetCursorPos( &cursorPos ); @@ -4086,6 +6159,13 @@ LRESULT APIENTRY MainWndProc( reg.ReadRegSettings( RegSettings ); + // Refresh dark mode state after loading theme override from registry + RefreshDarkModeState(); + + // Initialize save location strings from registry buffers + g_RecordingSaveLocation = g_RecordingSaveLocationBuffer; + g_ScreenshotSaveLocation = g_ScreenshotSaveLocationBuffer; + // Set g_RecordScaling based on the current recording format if (g_RecordingFormat == RecordingFormat::GIF) { g_RecordScaling = g_RecordScalingGIF; @@ -4294,6 +6374,10 @@ LRESULT APIENTRY MainWndProc( case SNIP_SAVE_HOTKEY: case SNIP_HOTKEY: { + OutputDebugStringW((L"[Snip] Hotkey received: " + std::to_wstring(LOWORD(wParam)) + + L" (SNIP_SAVE=" + std::to_wstring(SNIP_SAVE_HOTKEY) + + L" SNIP=" + std::to_wstring(SNIP_HOTKEY) + L")\n").c_str()); + // Block liveZoom liveDraw snip due to mirroring bug if( IsWindowVisible( g_hWndLiveZoom ) && ( GetWindowLongPtr( hWnd, GWL_EXSTYLE ) & WS_EX_LAYERED ) ) @@ -4339,10 +6423,8 @@ LRESULT APIENTRY MainWndProc( // Now copy crop or copy+save if( LOWORD( wParam ) == SNIP_SAVE_HOTKEY ) { - // Hide cursor for screen capture - ShowCursor(false); + // IDC_SAVE_CROP handles cursor hiding internally after region selection SendMessage( hWnd, WM_COMMAND, IDC_SAVE_CROP, ( zoomed ? 0 : SHALLOW_ZOOM ) ); - ShowCursor(true); } else { @@ -4378,6 +6460,22 @@ LRESULT APIENTRY MainWndProc( break; } + case SAVE_IMAGE_HOTKEY: + SendMessage(hWnd, WM_COMMAND, IDC_SAVE, 0); + break; + + case SAVE_CROP_HOTKEY: + SendMessage(hWnd, WM_COMMAND, IDC_SAVE_CROP, 0); + break; + + case COPY_IMAGE_HOTKEY: + SendMessage(hWnd, WM_COMMAND, IDC_COPY, 0); + break; + + case COPY_CROP_HOTKEY: + SendMessage(hWnd, WM_COMMAND, IDC_COPY_CROP, 0); + break; + case BREAK_HOTKEY: // // Go to break timer @@ -4528,6 +6626,12 @@ LRESULT APIENTRY MainWndProc( break; } + // Ignore recording hotkey when save dialog is open + if( g_bSaveInProgress ) + { + break; + } + // Start screen recording try { @@ -4731,6 +6835,12 @@ LRESULT APIENTRY MainWndProc( g_DrawingShape = FALSE; OutputDebug( L"Zoom on\n"); + // Register Ctrl+C and Ctrl+S hotkeys only during static zoom + RegisterHotKey(hWnd, COPY_IMAGE_HOTKEY, MOD_CONTROL | MOD_NOREPEAT, 'C'); + RegisterHotKey(hWnd, COPY_CROP_HOTKEY, MOD_CONTROL | MOD_SHIFT | MOD_NOREPEAT, 'C'); + RegisterHotKey(hWnd, SAVE_IMAGE_HOTKEY, MOD_CONTROL | MOD_NOREPEAT, 'S'); + RegisterHotKey(hWnd, SAVE_CROP_HOTKEY, MOD_CONTROL | MOD_SHIFT | MOD_NOREPEAT, 'S'); + #ifdef __ZOOMIT_POWERTOYS__ if( g_StartedByPowerToys ) { @@ -6230,7 +8340,7 @@ LRESULT APIENTRY MainWndProc( DeleteTypedText( &typedKeyList ); // 1 means don't reset the cursor. We get that for font resizing - // Only move the cursor if we're drawing, because otherwise the screen moves to center + // Only move the cursor if we're drawing, else the screen moves to center // on the new cursor position if( wParam != 1 && g_Drawing ) { @@ -6284,6 +8394,8 @@ LRESULT APIENTRY MainWndProc( InsertMenu( hPopupMenu, 0, MF_BYPOSITION|MF_SEPARATOR, 0, NULL ); InsertMenu( hPopupMenu, 0, MF_BYPOSITION, IDC_OPTIONS, L"&Options" ); } + // Apply dark mode theme to the menu + ApplyDarkModeToMenu( hPopupMenu ); TrackPopupMenu( hPopupMenu, 0, pt.x , pt.y, 0, hWnd, NULL ); DestroyMenu( hPopupMenu ); break; @@ -6384,11 +8496,14 @@ LRESULT APIENTRY MainWndProc( { // Reload the settings. This message is called from PowerToys after a setting is changed by the user. reg.ReadRegSettings(RegSettings); - + + // Refresh dark mode state after loading theme override from registry + RefreshDarkModeState(); + if (g_RecordingFormat == RecordingFormat::GIF) { g_RecordScaling = g_RecordScalingGIF; - g_RecordFrameRate = RECORDING_FORMAT_GIF_DEFAULT_FRAMERATE; + g_RecordFrameRate = RECORDING_FORMAT_GIF_DEFAULT_FRAMERATE; } else { @@ -6567,34 +8682,79 @@ LRESULT APIENTRY MainWndProc( // Open the Save As dialog and capture the desired file path and whether to // save the zoomed display or the source bitmap pixels. g_bSaveInProgress = true; - memset( &openFileName, 0, sizeof(openFileName )); - openFileName.lStructSize = OPENFILENAME_SIZE_VERSION_400; - openFileName.hwndOwner = hWnd; - openFileName.hInstance = static_cast(g_hInstance); - openFileName.nMaxFile = sizeof(filePath)/sizeof(filePath[0]); - openFileName.Flags = OFN_LONGNAMES|OFN_HIDEREADONLY|OFN_OVERWRITEPROMPT; - openFileName.lpstrTitle = L"Save zoomed screen..."; - openFileName.lpstrDefExt = NULL; // "*.png"; - openFileName.nFilterIndex = 1; - openFileName.lpstrFilter = L"Zoomed PNG\0*.png\0" - //"Zoomed BMP\0*.bmp\0" - "Actual size PNG\0*.png\0\0"; - //"Actual size BMP\0*.bmp\0\0"; - openFileName.lpstrFile = filePath; - if( GetSaveFileName( &openFileName ) ) + // Get a unique filename suggestion + auto suggestedName = GetUniqueScreenshotFilename(); + + // Create modern IFileSaveDialog + auto saveDialog = wil::CoCreateInstance( CLSID_FileSaveDialog ); + + FILEOPENDIALOGOPTIONS options; + if( SUCCEEDED( saveDialog->GetOptions( &options ) ) ) + saveDialog->SetOptions( options | FOS_FORCEFILESYSTEM | FOS_OVERWRITEPROMPT ); + + // Set file types - index is 1-based when retrieved via GetFileTypeIndex + COMDLG_FILTERSPEC fileTypes[] = { + { L"Zoomed PNG", L"*.png" }, + { L"Actual size PNG", L"*.png" } + }; + saveDialog->SetFileTypes( _countof( fileTypes ), fileTypes ); + saveDialog->SetFileTypeIndex( 1 ); // Default to "Zoomed PNG" + saveDialog->SetDefaultExtension( L"png" ); + saveDialog->SetFileName( suggestedName.c_str() ); + saveDialog->SetTitle( L"ZoomIt: Save Zoomed Screen..." ); + + // Set default folder to the last save location if available + if( !g_ScreenshotSaveLocation.empty() ) { - TCHAR targetFilePath[MAX_PATH]; - _tcscpy( targetFilePath, filePath ); - if( !_tcsrchr( targetFilePath, '.' ) ) + std::filesystem::path lastPath( g_ScreenshotSaveLocation ); + if( lastPath.has_parent_path() ) { - _tcscat( targetFilePath, L".png" ); + wil::com_ptr folderItem; + if( SUCCEEDED( SHCreateItemFromParsingName( lastPath.parent_path().c_str(), + nullptr, IID_PPV_ARGS( &folderItem ) ) ) ) + { + saveDialog->SetFolder( folderItem.get() ); + } + } + } + + OpenSaveDialogEvents* pEvents = new OpenSaveDialogEvents(); + DWORD dwCookie = 0; + saveDialog->Advise(pEvents, &dwCookie); + + UINT selectedFilterIndex = 1; + std::wstring selectedFilePath; + + if( SUCCEEDED( saveDialog->Show( hWnd ) ) ) + { + wil::com_ptr resultItem; + if( SUCCEEDED( saveDialog->GetResult( &resultItem ) ) ) + { + wil::unique_cotaskmem_string pathStr; + if( SUCCEEDED( resultItem->GetDisplayName( SIGDN_FILESYSPATH, &pathStr ) ) ) + { + selectedFilePath = pathStr.get(); + } + } + saveDialog->GetFileTypeIndex( &selectedFilterIndex ); + } + + saveDialog->Unadvise(dwCookie); + pEvents->Release(); + + if( !selectedFilePath.empty() ) + { + std::wstring targetFilePath = selectedFilePath; + if( targetFilePath.find(L'.') == std::wstring::npos ) + { + targetFilePath += L".png"; } - if( openFileName.nFilterIndex == 2 ) + if( selectedFilterIndex == 2 ) { // Save at actual size. - SavePng( targetFilePath, hbmActualSize.get() ); + SavePng( targetFilePath.c_str(), hbmActualSize.get() ); } else { @@ -6621,8 +8781,13 @@ LRESULT APIENTRY MainWndProc( saveWidth, saveHeight, SRCCOPY | CAPTUREBLT ); - SavePng( targetFilePath, hbmZoomed.get() ); + SavePng(targetFilePath.c_str(), hbmZoomed.get()); } + + // Remember the save location for next time and persist to registry + g_ScreenshotSaveLocation = targetFilePath; + wcsncpy_s(g_ScreenshotSaveLocationBuffer, g_ScreenshotSaveLocation.c_str(), _TRUNCATE); + reg.WriteRegSettings(RegSettings); } g_bSaveInProgress = false; @@ -7080,7 +9245,7 @@ LRESULT APIENTRY MainWndProc( return TRUE; case WM_DESTROY: - + CleanupDarkModeResources(); PostQuitMessage( 0 ); break; @@ -7884,6 +10049,11 @@ int APIENTRY wWinMain(_In_ HINSTANCE hInstance, _In_opt_ HINSTANCE hPrevInstance pEnableThemeDialogTexture = (type_pEnableThemeDialogTexture) GetProcAddress( GetModuleHandle( L"uxtheme.dll" ), "EnableThemeDialogTexture" ); + + // Initialize dark mode support early, before any windows are created + // This is required for popup menus to use dark mode + InitializeDarkMode(); + pMonitorFromPoint = (type_MonitorFromPoint) GetProcAddress( LoadLibrarySafe( L"User32.dll", DLL_LOAD_LOCATION_SYSTEM), "MonitorFromPoint" ); pGetMonitorInfo = (type_pGetMonitorInfo) GetProcAddress( LoadLibrarySafe( L"User32.dll", DLL_LOAD_LOCATION_SYSTEM), @@ -7934,7 +10104,7 @@ int APIENTRY wWinMain(_In_ HINSTANCE hInstance, _In_opt_ HINSTANCE hPrevInstance // Windows Server 2022 (and including Windows 11) introduced a bug where the cursor disappears // in live zoom. Use the full-screen magnifier as a workaround on those versions only. It is // currently impractical as a replacement; it requires calling MagSetInputTransform for all - // input to be transformed. Otherwise, some hit-testing is misdirected. MagSetInputTransform + // input to be transformed. Else, some hit-testing is misdirected. MagSetInputTransform // fails without token UI access, which is impractical; it requires copying the executable // under either %ProgramFiles% or %SystemRoot%, which requires elevation. // diff --git a/src/modules/ZoomIt/ZoomIt/pch.h b/src/modules/ZoomIt/ZoomIt/pch.h index 2afdc4e542..12b0d326b2 100644 --- a/src/modules/ZoomIt/ZoomIt/pch.h +++ b/src/modules/ZoomIt/ZoomIt/pch.h @@ -7,8 +7,10 @@ #include #include #include +#include #include #include +#include #include #include #include @@ -41,12 +43,15 @@ #include #include #include +#include +#include #include #include #include #include #include #include +#include #include #include @@ -69,6 +74,9 @@ #include #include #include +#include +#include +#include // STL diff --git a/src/modules/ZoomIt/ZoomIt/resource.h b/src/modules/ZoomIt/ZoomIt/resource.h index 2458e8ce75..c3cffd6d7b 100644 --- a/src/modules/ZoomIt/ZoomIt/resource.h +++ b/src/modules/ZoomIt/ZoomIt/resource.h @@ -12,6 +12,7 @@ // Non-localizable ////////////////////////////// #define IDC_AUDIO 117 +#define IDD_VIDEO_TRIM 119 #define IDC_LINK 1000 #define IDC_ALT 1001 #define IDC_CTRL 1002 @@ -94,9 +95,22 @@ #define IDC_DEMOTYPE_STATIC2 1074 #define IDC_COPYRIGHT 1075 #define IDC_RECORD_FORMAT 1076 +#define IDC_TRIM_POSITION_LABEL 1087 +#define IDC_TRIM_PREVIEW 1088 +#define IDC_TRIM_TIMELINE 1089 +#define IDC_TRIM_PLAY_PAUSE 1090 +#define IDC_TRIM_REWIND 1091 +#define IDC_TRIM_FORWARD 1092 +#define IDC_TRIM_DURATION_LABEL 1094 +#define IDC_TRIM_SKIP_START 1095 +#define IDC_TRIM_SKIP_END 1096 +#define IDC_TRIM_VOLUME 1097 +#define IDC_TRIM_VOLUME_ICON 1098 #define IDC_PEN_WIDTH 1105 #define IDC_TIMER 1106 #define IDC_SMOOTH_IMAGE 1107 +#define IDC_CAPTURE_SYSTEM_AUDIO 1108 +#define IDC_MICROPHONE_LABEL 1109 #define IDC_SAVE 40002 #define IDC_COPY 40004 #define IDC_RECORD 40006 @@ -109,9 +123,9 @@ // #ifdef APSTUDIO_INVOKED #ifndef APSTUDIO_READONLY_SYMBOLS -#define _APS_NEXT_RESOURCE_VALUE 118 +#define _APS_NEXT_RESOURCE_VALUE 120 #define _APS_NEXT_COMMAND_VALUE 40013 -#define _APS_NEXT_CONTROL_VALUE 1078 +#define _APS_NEXT_CONTROL_VALUE 1099 #define _APS_NEXT_SYMED_VALUE 101 #endif #endif diff --git a/src/modules/ZoomIt/ZoomItModuleInterface/ZoomItModuleInterface.vcxproj b/src/modules/ZoomIt/ZoomItModuleInterface/ZoomItModuleInterface.vcxproj index c922d38969..955de8de91 100644 --- a/src/modules/ZoomIt/ZoomItModuleInterface/ZoomItModuleInterface.vcxproj +++ b/src/modules/ZoomIt/ZoomItModuleInterface/ZoomItModuleInterface.vcxproj @@ -12,13 +12,13 @@ DynamicLibrary true - v143 + Unicode DynamicLibrary false - v143 + true Unicode diff --git a/src/modules/ZoomIt/ZoomItSettingsInterop/ZoomItSettingsInterop.vcxproj b/src/modules/ZoomIt/ZoomItSettingsInterop/ZoomItSettingsInterop.vcxproj index 21998a40da..aa53283607 100644 --- a/src/modules/ZoomIt/ZoomItSettingsInterop/ZoomItSettingsInterop.vcxproj +++ b/src/modules/ZoomIt/ZoomItSettingsInterop/ZoomItSettingsInterop.vcxproj @@ -19,7 +19,7 @@ DynamicLibrary - v143 + Unicode false diff --git a/src/modules/alwaysontop/AlwaysOnTop/AlwaysOnTop.cpp b/src/modules/alwaysontop/AlwaysOnTop/AlwaysOnTop.cpp index 9d2b076b52..ce40f2c912 100644 --- a/src/modules/alwaysontop/AlwaysOnTop/AlwaysOnTop.cpp +++ b/src/modules/alwaysontop/AlwaysOnTop/AlwaysOnTop.cpp @@ -162,11 +162,11 @@ LRESULT AlwaysOnTop::WndProc(HWND window, UINT message, WPARAM wparam, LPARAM lp } else if (hotkeyId == static_cast(HotkeyId::IncreaseOpacity)) { - AdjustTransparency(fw, Settings::transparencyStep); + StepWindowTransparency(fw, Settings::transparencyStep); } else if (hotkeyId == static_cast(HotkeyId::DecreaseOpacity)) { - AdjustTransparency(fw, -Settings::transparencyStep); + StepWindowTransparency(fw, -Settings::transparencyStep); } } } @@ -204,9 +204,8 @@ void AlwaysOnTop::ProcessCommand(HWND window) } // Restore transparency when unpinning - RemoveTransparency(window); + RestoreWindowAlpha(window); m_windowOriginalLayeredState.erase(window); - m_windowTransparency.erase(window); Trace::AlwaysOnTop::UnpinWindow(); } @@ -367,13 +366,13 @@ void AlwaysOnTop::RegisterLLKH() case WAIT_OBJECT_0 + 2: // Increase opacity event if (HWND fw{ GetForegroundWindow() }) { - AdjustTransparency(fw, Settings::transparencyStep); + StepWindowTransparency(fw, Settings::transparencyStep); } break; case WAIT_OBJECT_0 + 3: // Decrease opacity event if (HWND fw{ GetForegroundWindow() }) { - AdjustTransparency(fw, -Settings::transparencyStep); + StepWindowTransparency(fw, -Settings::transparencyStep); } break; case WAIT_OBJECT_0 + 4: // Message queue @@ -426,11 +425,10 @@ void AlwaysOnTop::UnpinAll() Logger::error(L"Unpinning topmost window failed"); } // Restore transparency when unpinning all - RemoveTransparency(topWindow); + RestoreWindowAlpha(topWindow); } m_topmostWindows.clear(); - m_windowTransparency.clear(); m_windowOriginalLayeredState.clear(); } @@ -515,7 +513,6 @@ void AlwaysOnTop::HandleWinHookEvent(WinHookEvent* data) noexcept for (const auto window : toErase) { m_topmostWindows.erase(window); - m_windowTransparency.erase(window); m_windowOriginalLayeredState.erase(window); } @@ -617,182 +614,4 @@ void AlwaysOnTop::RefreshBorders() } } } -} - -// Transparency adjustment methods -HWND AlwaysOnTop::GetTransparencyTarget(HWND window) -{ - if (!window || !IsWindow(window)) - { - return nullptr; - } - - // Only allow transparency adjustment on tracked/pinned windows - if (!IsTracked(window) && !IsPinned(window)) - { - return nullptr; - } - - // Use GA_ROOTOWNER to get the root owner window - HWND targetWindow = GetAncestor(window, GA_ROOTOWNER); - if (!targetWindow) - { - targetWindow = window; - } - - // Filter out desktop, shell, invisible windows - if (targetWindow == GetDesktopWindow() || targetWindow == GetShellWindow()) - { - return nullptr; - } - if (!IsWindowVisible(targetWindow)) - { - return nullptr; - } - - return targetWindow; -} - -void AlwaysOnTop::AdjustTransparency(HWND window, int delta) -{ - HWND targetWindow = GetTransparencyTarget(window); - if (!targetWindow) - { - return; - } - - auto it = m_windowTransparency.find(targetWindow); - int currentTransparency = (it != m_windowTransparency.end()) ? it->second : Settings::maxTransparencyPercentage; - int newTransparency = (std::max)(Settings::minTransparencyPercentage, - (std::min)(Settings::maxTransparencyPercentage, currentTransparency + delta)); - - if (newTransparency != currentTransparency) - { - SetTransparency(targetWindow, newTransparency); - - if (AlwaysOnTopSettings::settings().enableSound) - { - m_sound.Play(delta > 0 ? Sound::Type::IncreaseOpacity : Sound::Type::DecreaseOpacity); - } - - Logger::trace(L"Transparency adjusted to {}%", newTransparency); - } -} - -void AlwaysOnTop::SetTransparency(HWND window, int percentage) -{ - if (!window || !IsWindow(window)) - { - return; - } - - percentage = (std::max)(Settings::minTransparencyPercentage, - (std::min)(Settings::maxTransparencyPercentage, percentage)); - - m_windowTransparency[window] = percentage; - - if (percentage == Settings::maxTransparencyPercentage) - { - RemoveTransparency(window); - return; - } - - LONG exStyle = GetWindowLong(window, GWL_EXSTYLE); - bool isCurrentlyLayered = (exStyle & WS_EX_LAYERED) != 0; - - // Cache original state on first transparency application - if (m_windowOriginalLayeredState.find(window) == m_windowOriginalLayeredState.end()) - { - WindowLayeredState state; - state.hadLayeredStyle = isCurrentlyLayered; - - if (isCurrentlyLayered) - { - BYTE alpha = 255; - COLORREF colorKey = 0; - DWORD flags = 0; - if (GetLayeredWindowAttributes(window, &colorKey, &alpha, &flags)) - { - state.originalAlpha = alpha; - state.usedColorKey = (flags & LWA_COLORKEY) != 0; - state.colorKey = colorKey; - } - else - { - Logger::warn(L"GetLayeredWindowAttributes failed for layered window, skipping"); - return; - } - } - m_windowOriginalLayeredState[window] = state; - } - - // Clear WS_EX_LAYERED first to ensure SetLayeredWindowAttributes works - if (isCurrentlyLayered) - { - SetWindowLong(window, GWL_EXSTYLE, exStyle & ~WS_EX_LAYERED); - SetWindowPos(window, nullptr, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED); - exStyle = GetWindowLong(window, GWL_EXSTYLE); - } - - BYTE alphaValue = static_cast((255 * percentage) / 100); - SetWindowLong(window, GWL_EXSTYLE, exStyle | WS_EX_LAYERED); - SetLayeredWindowAttributes(window, 0, alphaValue, LWA_ALPHA); -} - -void AlwaysOnTop::RemoveTransparency(HWND window) -{ - if (!window || !IsWindow(window)) - { - return; - } - - LONG exStyle = GetWindowLong(window, GWL_EXSTYLE); - auto it = m_windowOriginalLayeredState.find(window); - - if (it != m_windowOriginalLayeredState.end()) - { - const auto& originalState = it->second; - - if (originalState.hadLayeredStyle) - { - // Window originally had WS_EX_LAYERED - restore original attributes - // Clear and re-add to ensure clean state - if (exStyle & WS_EX_LAYERED) - { - SetWindowLong(window, GWL_EXSTYLE, exStyle & ~WS_EX_LAYERED); - exStyle = GetWindowLong(window, GWL_EXSTYLE); - } - SetWindowLong(window, GWL_EXSTYLE, exStyle | WS_EX_LAYERED); - - // Restore original alpha and/or color key - DWORD flags = LWA_ALPHA; - if (originalState.usedColorKey) - { - flags |= LWA_COLORKEY; - } - SetLayeredWindowAttributes(window, originalState.colorKey, originalState.originalAlpha, flags); - SetWindowPos(window, nullptr, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED); - } - else - { - // Window originally didn't have WS_EX_LAYERED - remove it completely - if (exStyle & WS_EX_LAYERED) - { - SetLayeredWindowAttributes(window, 0, 255, LWA_ALPHA); - SetWindowLong(window, GWL_EXSTYLE, exStyle & ~WS_EX_LAYERED); - SetWindowPos(window, nullptr, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED); - } - } - - m_windowOriginalLayeredState.erase(it); - } - else - { - // Fallback: no cached state, just remove layered style - if (exStyle & WS_EX_LAYERED) - { - SetLayeredWindowAttributes(window, 0, 255, LWA_ALPHA); - SetWindowLong(window, GWL_EXSTYLE, exStyle & ~WS_EX_LAYERED); - } - } } \ No newline at end of file diff --git a/src/modules/alwaysontop/AlwaysOnTop/AlwaysOnTop.h b/src/modules/alwaysontop/AlwaysOnTop/AlwaysOnTop.h index 92b38172ba..438eaa64c4 100644 --- a/src/modules/alwaysontop/AlwaysOnTop/AlwaysOnTop.h +++ b/src/modules/alwaysontop/AlwaysOnTop/AlwaysOnTop.h @@ -51,7 +51,6 @@ private: HWND m_window{ nullptr }; HINSTANCE m_hinstance; std::map> m_topmostWindows{}; - std::map m_windowTransparency{}; // Track transparency per window (20-100) // Store original window layered state for proper restoration struct WindowLayeredState { @@ -95,10 +94,10 @@ private: void RefreshBorders(); // Transparency methods - HWND GetTransparencyTarget(HWND window); - void AdjustTransparency(HWND window, int delta); - void SetTransparency(HWND window, int percentage); - void RemoveTransparency(HWND window); + HWND ResolveTransparencyTargetWindow(HWND window); + void StepWindowTransparency(HWND window, int delta); + void ApplyWindowAlpha(HWND window, int percentage); + void RestoreWindowAlpha(HWND window); virtual void SettingsUpdate(SettingId type) override; diff --git a/src/modules/alwaysontop/AlwaysOnTop/AlwaysOnTop.vcxproj b/src/modules/alwaysontop/AlwaysOnTop/AlwaysOnTop.vcxproj index bf3e5c6851..f3d4b6f75d 100644 --- a/src/modules/alwaysontop/AlwaysOnTop/AlwaysOnTop.vcxproj +++ b/src/modules/alwaysontop/AlwaysOnTop/AlwaysOnTop.vcxproj @@ -60,7 +60,7 @@ Application - v143 + Unicode Spectre diff --git a/src/modules/alwaysontop/AlwaysOnTop/Settings.h b/src/modules/alwaysontop/AlwaysOnTop/Settings.h index 7b674c5cf0..9c0624298e 100644 --- a/src/modules/alwaysontop/AlwaysOnTop/Settings.h +++ b/src/modules/alwaysontop/AlwaysOnTop/Settings.h @@ -15,6 +15,9 @@ class SettingsObserver; struct Settings { PowerToysSettings::HotkeyObject hotkey = PowerToysSettings::HotkeyObject::from_settings(true, true, false, false, 84); // win + ctrl + T + static constexpr int minTransparencyPercentage = 20; // minimum transparency (can't go below 20%) + static constexpr int maxTransparencyPercentage = 100; // maximum (fully opaque) + static constexpr int transparencyStep = 10; // step size for +/- adjustment bool enableFrame = true; bool enableSound = true; bool roundCornersEnabled = true; diff --git a/src/modules/alwaysontop/AlwaysOnTop/Sound.h b/src/modules/alwaysontop/AlwaysOnTop/Sound.h index e8f8dd5de4..3bb868b179 100644 --- a/src/modules/alwaysontop/AlwaysOnTop/Sound.h +++ b/src/modules/alwaysontop/AlwaysOnTop/Sound.h @@ -2,7 +2,6 @@ #include "pch.h" -#include #include // sound class Sound @@ -12,12 +11,10 @@ public: { On, Off, + IncreaseOpacity, + DecreaseOpacity, }; - Sound() - : isPlaying(false) - {} - void Play(Type type) { BOOL success = false; @@ -29,6 +26,12 @@ public: case Type::Off: success = PlaySound(TEXT("Media\\Speech Sleep.wav"), NULL, SND_FILENAME | SND_ASYNC); break; + case Type::IncreaseOpacity: + success = PlaySound(TEXT("Media\\Windows Hardware Insert.wav"), NULL, SND_FILENAME | SND_ASYNC); + break; + case Type::DecreaseOpacity: + success = PlaySound(TEXT("Media\\Windows Hardware Remove.wav"), NULL, SND_FILENAME | SND_ASYNC); + break; default: break; } @@ -38,7 +41,4 @@ public: Logger::error(L"Sound playing error"); } } - -private: - std::atomic isPlaying; }; \ No newline at end of file diff --git a/src/modules/alwaysontop/AlwaysOnTopModuleInterface/AlwaysOnTopModuleInterface.vcxproj b/src/modules/alwaysontop/AlwaysOnTopModuleInterface/AlwaysOnTopModuleInterface.vcxproj index 5f63a0e628..c9be3969e2 100644 --- a/src/modules/alwaysontop/AlwaysOnTopModuleInterface/AlwaysOnTopModuleInterface.vcxproj +++ b/src/modules/alwaysontop/AlwaysOnTopModuleInterface/AlwaysOnTopModuleInterface.vcxproj @@ -11,7 +11,7 @@ DynamicLibrary - v143 + diff --git a/src/modules/alwaysontop/AlwaysOnTopModuleInterface/dllmain.cpp b/src/modules/alwaysontop/AlwaysOnTopModuleInterface/dllmain.cpp index 1ed96e79bd..bc52137ed2 100644 --- a/src/modules/alwaysontop/AlwaysOnTopModuleInterface/dllmain.cpp +++ b/src/modules/alwaysontop/AlwaysOnTopModuleInterface/dllmain.cpp @@ -105,17 +105,28 @@ public: } } - virtual bool on_hotkey(size_t /*hotkeyId*/) override + virtual bool on_hotkey(size_t hotkeyId) override { if (m_enabled) { - Logger::trace(L"AlwaysOnTop hotkey pressed"); + Logger::trace(L"AlwaysOnTop hotkey pressed, id={}", hotkeyId); if (!is_process_running()) { Enable(); } - SetEvent(m_hPinEvent); + if (hotkeyId == 0) + { + SetEvent(m_hPinEvent); + } + else if (hotkeyId == 1) + { + SetEvent(m_hIncreaseOpacityEvent); + } + else if (hotkeyId == 2) + { + SetEvent(m_hDecreaseOpacityEvent); + } return true; } @@ -125,19 +136,48 @@ public: virtual size_t get_hotkeys(Hotkey* hotkeys, size_t buffer_size) override { + size_t count = 0; + + // Hotkey 0: Pin/Unpin (e.g., Win+Ctrl+T) if (m_hotkey.key) { - if (hotkeys && buffer_size >= 1) + if (hotkeys && buffer_size > count) { - hotkeys[0] = m_hotkey; + hotkeys[count] = m_hotkey; + Logger::trace(L"AlwaysOnTop hotkey[0]: win={}, ctrl={}, shift={}, alt={}, key={}", + m_hotkey.win, m_hotkey.ctrl, m_hotkey.shift, m_hotkey.alt, m_hotkey.key); } + count++; + } - return 1; - } - else + // Hotkey 1: Increase opacity (same modifiers + VK_OEM_PLUS '=') + if (m_hotkey.key) { - return 0; + if (hotkeys && buffer_size > count) + { + hotkeys[count] = m_hotkey; + hotkeys[count].key = VK_OEM_PLUS; // '=' key + Logger::trace(L"AlwaysOnTop hotkey[1] (increase opacity): win={}, ctrl={}, shift={}, alt={}, key={}", + hotkeys[count].win, hotkeys[count].ctrl, hotkeys[count].shift, hotkeys[count].alt, hotkeys[count].key); + } + count++; } + + // Hotkey 2: Decrease opacity (same modifiers + VK_OEM_MINUS '-') + if (m_hotkey.key) + { + if (hotkeys && buffer_size > count) + { + hotkeys[count] = m_hotkey; + hotkeys[count].key = VK_OEM_MINUS; // '-' key + Logger::trace(L"AlwaysOnTop hotkey[2] (decrease opacity): win={}, ctrl={}, shift={}, alt={}, key={}", + hotkeys[count].win, hotkeys[count].ctrl, hotkeys[count].shift, hotkeys[count].alt, hotkeys[count].key); + } + count++; + } + + Logger::trace(L"AlwaysOnTop get_hotkeys returning count={}", count); + return count; } // Enable the powertoy @@ -175,6 +215,8 @@ public: app_key = NonLocalizable::ModuleKey; m_hPinEvent = CreateDefaultEvent(CommonSharedConstants::ALWAYS_ON_TOP_PIN_EVENT); m_hTerminateEvent = CreateDefaultEvent(CommonSharedConstants::ALWAYS_ON_TOP_TERMINATE_EVENT); + m_hIncreaseOpacityEvent = CreateDefaultEvent(CommonSharedConstants::ALWAYS_ON_TOP_INCREASE_OPACITY_EVENT); + m_hDecreaseOpacityEvent = CreateDefaultEvent(CommonSharedConstants::ALWAYS_ON_TOP_DECREASE_OPACITY_EVENT); init_settings(); } @@ -292,6 +334,8 @@ private: // Handle to event used to pin/unpin windows HANDLE m_hPinEvent; HANDLE m_hTerminateEvent; + HANDLE m_hIncreaseOpacityEvent; + HANDLE m_hDecreaseOpacityEvent; }; extern "C" __declspec(dllexport) PowertoyModuleIface* __cdecl powertoy_create() diff --git a/src/modules/awake/AwakeModuleInterface/AwakeModuleInterface.vcxproj b/src/modules/awake/AwakeModuleInterface/AwakeModuleInterface.vcxproj index e1ce05608a..718e13e462 100644 --- a/src/modules/awake/AwakeModuleInterface/AwakeModuleInterface.vcxproj +++ b/src/modules/awake/AwakeModuleInterface/AwakeModuleInterface.vcxproj @@ -8,7 +8,7 @@ Awake AwakeModuleInterface PowerToys.AwakeModuleInterface - v143 + diff --git a/src/modules/awake/README.md b/src/modules/awake/README.md index 1ca2f9a7dd..43ece7760a 100644 --- a/src/modules/awake/README.md +++ b/src/modules/awake/README.md @@ -105,7 +105,7 @@ PowerToys.Awake.exe --pid 1234 ### Prerequisites -- Visual Studio 2022 with C++ and .NET workloads +- Visual Studio 2022 or 2026 with C++ and .NET workloads - Windows SDK 10.0.26100.0 or later ### Build Commands diff --git a/src/modules/cmdNotFound/CmdNotFoundModuleInterface/CmdNotFoundModuleInterface.vcxproj b/src/modules/cmdNotFound/CmdNotFoundModuleInterface/CmdNotFoundModuleInterface.vcxproj index 34d556683a..58be2b1c5f 100644 --- a/src/modules/cmdNotFound/CmdNotFoundModuleInterface/CmdNotFoundModuleInterface.vcxproj +++ b/src/modules/cmdNotFound/CmdNotFoundModuleInterface/CmdNotFoundModuleInterface.vcxproj @@ -7,7 +7,7 @@ {0014d652-901f-4456-8d65-06fc5f997fb0} CmdNotFoundModuleInterface PowerToys.CmdNotFoundModuleInterface - v143 + CmdNotFoundModuleInterface diff --git a/src/modules/cmdpal/CmdPalKeyboardService/CmdPalKeyboardService.vcxproj b/src/modules/cmdpal/CmdPalKeyboardService/CmdPalKeyboardService.vcxproj index f891ce96e6..cc5bdfeb26 100644 --- a/src/modules/cmdpal/CmdPalKeyboardService/CmdPalKeyboardService.vcxproj +++ b/src/modules/cmdpal/CmdPalKeyboardService/CmdPalKeyboardService.vcxproj @@ -42,7 +42,7 @@ DynamicLibrary - v143 + Unicode false diff --git a/src/modules/cmdpal/CmdPalModuleInterface/CmdPalModuleInterface.vcxproj b/src/modules/cmdpal/CmdPalModuleInterface/CmdPalModuleInterface.vcxproj index 433e7599d5..ba6da61ec2 100644 --- a/src/modules/cmdpal/CmdPalModuleInterface/CmdPalModuleInterface.vcxproj +++ b/src/modules/cmdpal/CmdPalModuleInterface/CmdPalModuleInterface.vcxproj @@ -15,13 +15,13 @@ DynamicLibrary true - v143 + Unicode DynamicLibrary false - v143 + true Unicode diff --git a/src/modules/cmdpal/CommandPalette.slnf b/src/modules/cmdpal/CommandPalette.slnf index aa8f1165d9..c6ccbb7338 100644 --- a/src/modules/cmdpal/CommandPalette.slnf +++ b/src/modules/cmdpal/CommandPalette.slnf @@ -15,6 +15,7 @@ "src\\modules\\cmdpal\\Microsoft.CmdPal.UI.ViewModels\\Microsoft.CmdPal.UI.ViewModels.csproj", "src\\modules\\cmdpal\\Microsoft.CmdPal.UI\\Microsoft.CmdPal.UI.csproj", "src\\modules\\cmdpal\\Microsoft.Terminal.UI\\Microsoft.Terminal.UI.vcxproj", + "src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.Core.Common.UnitTests\\Microsoft.CmdPal.Core.Common.UnitTests.csproj", "src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.Ext.Apps.UnitTests\\Microsoft.CmdPal.Ext.Apps.UnitTests.csproj", "src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.Ext.Bookmarks.UnitTests\\Microsoft.CmdPal.Ext.Bookmarks.UnitTests.csproj", "src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.Ext.Calc.UnitTests\\Microsoft.CmdPal.Ext.Calc.UnitTests.csproj", @@ -29,6 +30,7 @@ "src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.Ext.WindowWalker.UnitTests\\Microsoft.CmdPal.Ext.WindowWalker.UnitTests.csproj", "src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.UI.ViewModels.UnitTests\\Microsoft.CmdPal.UI.ViewModels.UnitTests.csproj", "src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.UITests\\Microsoft.CmdPal.UITests.csproj", + "src\\modules\\cmdpal\\Tests\\Microsoft.CommandPalette.Extensions.Toolkit.UnitTests\\Microsoft.CommandPalette.Extensions.Toolkit.UnitTests.csproj", "src\\modules\\cmdpal\\ext\\Microsoft.CmdPal.Ext.Apps\\Microsoft.CmdPal.Ext.Apps.csproj", "src\\modules\\cmdpal\\ext\\Microsoft.CmdPal.Ext.Bookmark\\Microsoft.CmdPal.Ext.Bookmarks.csproj", "src\\modules\\cmdpal\\ext\\Microsoft.CmdPal.Ext.Calc\\Microsoft.CmdPal.Ext.Calc.csproj", diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Microsoft.CmdPal.Core.Common.csproj b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Microsoft.CmdPal.Core.Common.csproj index a6b270799c..300264967d 100644 --- a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Microsoft.CmdPal.Core.Common.csproj +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Microsoft.CmdPal.Core.Common.csproj @@ -9,4 +9,18 @@ + + + True + True + Resources.resx + + + + + + ResXFileCodeGenerator + + +
diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Properties/Resources.Designer.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Properties/Resources.Designer.cs new file mode 100644 index 0000000000..052da7deb1 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Properties/Resources.Designer.cs @@ -0,0 +1,76 @@ +ο»Ώ//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace Microsoft.CmdPal.Core.Common.Properties { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class Resources { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal Resources() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Microsoft.CmdPal.Core.Common.Properties.Resources", typeof(Resources).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to This is an error report generated by Windows Command Palette. + ///If you are seeing this, it means something went a little sideways in the app. + ///You can help us fix it by filing a report at https://aka.ms/powerToysReportBug. + /// + ///(While you’re at it, give the details below a quick skim β€” just to make sure there’s nothing personal you’d prefer not to share. It’s rare, but sometimes little surprises sneak in.). + /// + internal static string ErrorReport_Global_Preamble { + get { + return ResourceManager.GetString("ErrorReport_Global_Preamble", resourceCulture); + } + } + } +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Properties/Resources.resx b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Properties/Resources.resx new file mode 100644 index 0000000000..e2aa867ad2 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Properties/Resources.resx @@ -0,0 +1,127 @@ +ο»Ώ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + This is an error report generated by Windows Command Palette. +If you are seeing this, it means something went a little sideways in the app. +You can help us fix it by filing a report at https://aka.ms/powerToysReportBug. + +(While you’re at it, give the details below a quick skim β€” just to make sure there’s nothing personal you’d prefer not to share. It’s rare, but sometimes little surprises sneak in.) + + \ No newline at end of file diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Reports/ErrorReportBuilder.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Reports/ErrorReportBuilder.cs new file mode 100644 index 0000000000..0c966f2593 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Reports/ErrorReportBuilder.cs @@ -0,0 +1,118 @@ +ο»Ώ// 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.Globalization; +using System.Runtime.InteropServices; +using System.Security.Principal; +using Microsoft.CmdPal.Core.Common.Services.Sanitizer; +using Windows.ApplicationModel; + +namespace Microsoft.CmdPal.Core.Common.Services.Reports; + +public sealed class ErrorReportBuilder : IErrorReportBuilder +{ + private readonly ErrorReportSanitizer _sanitizer = new(); + + private static string Preamble => Properties.Resources.ErrorReport_Global_Preamble; + + public string BuildReport(Exception exception, string context, bool redactPii = true) + { + ArgumentNullException.ThrowIfNull(exception); + + var exceptionMessage = CoalesceExceptionMessage(exception); + var sanitizedMessage = redactPii ? _sanitizer.Sanitize(exceptionMessage) : exceptionMessage; + var sanitizedFormattedException = redactPii ? _sanitizer.Sanitize(exception.ToString()) : exception.ToString(); + + // Note: + // - do not localize technical part of the report, we need to ensure it can be read by developers + // - keep timestamp format should be consistent with the log (makes it easier to search) + var technicalContent = + $""" + ============================================================ + Summary: + Message: {sanitizedMessage} + Type: {exception.GetType().FullName} + Source: {exception.Source ?? "N/A"} + Time: {DateTime.Now:yyyy-MM-dd HH:mm:ss.fffffff} + HRESULT: 0x{exception.HResult:X8} ({exception.HResult}) + Context: {context ?? "N/A"} + + Application: + App version: {GetAppVersionSafe()} + Is elevated: {GetElevationStatus()} + + Environment: + OS version: {RuntimeInformation.OSDescription} + OS architecture: {RuntimeInformation.OSArchitecture} + Runtime identifier: {RuntimeInformation.RuntimeIdentifier} + Framework: {RuntimeInformation.FrameworkDescription} + Process architecture: {RuntimeInformation.ProcessArchitecture} + Culture: {CultureInfo.CurrentCulture.Name} + UI culture: {CultureInfo.CurrentUICulture.Name} + + Stack Trace: + {exception.StackTrace} + + ------------------ Full Exception Details ------------------ + {sanitizedFormattedException} + + ============================================================ + """; + + return $""" + {Preamble} + {technicalContent} + """; + } + + private static string GetElevationStatus() + { + // Note: do not localize technical part of the report, we need to ensure it can be read by developers + try + { + var isElevated = new WindowsPrincipal(WindowsIdentity.GetCurrent()).IsInRole(WindowsBuiltInRole.Administrator); + return isElevated ? "yes" : "no"; + } + catch (Exception) + { + return "Failed to determine elevation status"; + } + } + + private static string GetAppVersionSafe() + { + // Note: do not localize technical part of the report, we need to ensure it can be read by developers + try + { + var version = Package.Current.Id.Version; + return $"{version.Major}.{version.Minor}.{version.Build}.{version.Revision}"; + } + catch (Exception) + { + return "Failed to retrieve app version"; + } + } + + private static string CoalesceExceptionMessage(Exception exception) + { + // let's try to get a message from the exception or inferred it from the HRESULT + // to show at least something + var message = exception.Message; + if (string.IsNullOrWhiteSpace(message)) + { + var temp = Marshal.GetExceptionForHR(exception.HResult)?.Message; + if (!string.IsNullOrWhiteSpace(temp)) + { + message = temp + $" (inferred from HRESULT 0x{exception.HResult:X8})"; + } + } + + if (string.IsNullOrWhiteSpace(message)) + { + message = "No message available"; + } + + return message; + } +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Reports/IErrorReportBuilder.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Reports/IErrorReportBuilder.cs new file mode 100644 index 0000000000..77487b01e5 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Reports/IErrorReportBuilder.cs @@ -0,0 +1,33 @@ +ο»Ώ// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.Core.Common.Services.Reports; + +/// +/// Defines a contract for creating human-readable error reports from exceptions, +/// suitable for logs, telemetry, or user-facing diagnostics. +/// +/// +/// Implementations should ensure reports are consistent and optionally redact +/// personally identifiable or sensitive information when requested. +/// +public interface IErrorReportBuilder +{ + /// + /// Builds a formatted error report for the specified and . + /// + /// The exception that triggered the error report. + /// + /// A short, human-readable description of where or what was being executed when the error occurred + /// (e.g., the operation name, component, or scenario). + /// + /// + /// When true, attempts to remove or obfuscate personally identifiable or sensitive information + /// (such as file paths, emails, machine/usernames, tokens). Defaults to true. + /// + /// + /// A formatted string containing the error report, suitable for logging or telemetry submission. + /// + string BuildReport(Exception exception, string context, bool redactPii = true); +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/Abstraction/ITextSanitizer.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/Abstraction/ITextSanitizer.cs new file mode 100644 index 0000000000..85b7973bf9 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/Abstraction/ITextSanitizer.cs @@ -0,0 +1,61 @@ +// 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 Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction; + +/// +/// Defines a service that sanitizes text by applying a set of configurable, regex-based rules. +/// Typical use cases include masking secrets, removing PII, or normalizing logs. +/// +/// +/// - Rules are applied in their registered order; rule ordering may affect the final output. +/// - Each rule should have a unique description that acts as its identifier. +/// +/// +public interface ITextSanitizer +{ + /// + /// Sanitizes the specified input by applying all registered rules in order. + /// + /// The input text to sanitize. Implementations should handle safely. + /// The sanitized text after all rules are applied. + string Sanitize(string? input); + + /// + /// Adds a sanitization rule using a .NET regular expression pattern and a replacement string. + /// + /// A .NET regular expression pattern used to match text to sanitize. + /// + /// The replacement text used by Regex.Replace. Supports standard regex replacement tokens, + /// including numbered groups ($1) and named groups (${name}). + /// + /// + /// A human-readable, unique identifier for the rule. Used to list, test, and remove the rule. + /// + /// + /// Implementations typically validate is a valid regex and may reject duplicate values. + /// + void AddRule(string pattern, string replacement, string description = ""); + + /// + /// Removes a previously added rule identified by its . + /// + /// The unique description of the rule to remove. + void RemoveRule(string description); + + /// + /// Gets a read-only snapshot of the currently registered sanitization rules in application order. + /// + /// A read-only list of items. + IReadOnlyList GetRules(); + + /// + /// Tests a single rule, identified by , against the provided , + /// without applying other rules. + /// + /// The input text to test. + /// The description (identifier) of the rule to test. + /// The result of applying only the specified rule to the input. + string TestRule(string input, string ruleDescription); +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/Abstraction/SanitizationRule.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/Abstraction/SanitizationRule.cs new file mode 100644 index 0000000000..27460fafd5 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/Abstraction/SanitizationRule.cs @@ -0,0 +1,36 @@ +ο»Ώ// 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.RegularExpressions; + +namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction; + +public readonly record struct SanitizationRule +{ + public SanitizationRule(Regex regex, string replacement, string description = "") + { + Regex = regex; + Replacement = replacement; + Evaluator = null; + Description = description; + } + + public SanitizationRule(Regex regex, MatchEvaluator evaluator, string description = "") + { + Regex = regex; + Evaluator = evaluator; + Replacement = null; + Description = description; + } + + public Regex Regex { get; } + + public string? Replacement { get; } + + public MatchEvaluator? Evaluator { get; } + + public string Description { get; } + + public override string ToString() => $"{Description}: {Regex} -> {Replacement ?? ""}"; +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/ConnectionStringRuleProvider.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/ConnectionStringRuleProvider.cs new file mode 100644 index 0000000000..00fffbcb84 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/ConnectionStringRuleProvider.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Text.RegularExpressions; +using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction; + +namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer; + +internal sealed partial class ConnectionStringRuleProvider : ISanitizationRuleProvider +{ + [GeneratedRegex(@"(Server|Data Source|Initial Catalog|Database|User ID|Username|Password|Pwd|Uid)\s*=\s*(?:""[^""]*""|'[^']*'|[^;,\s]+)", + SanitizerDefaults.DefaultOptions, SanitizerDefaults.DefaultMatchTimeoutMs)] + private static partial Regex ConnectionParamRx(); + + public IEnumerable GetRules() + { + yield return new(ConnectionParamRx(), "$1=[REDACTED]", "Connection string parameters"); + } +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/EnvironmentPropertiesRuleProvider.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/EnvironmentPropertiesRuleProvider.cs new file mode 100644 index 0000000000..4fcb779e35 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/EnvironmentPropertiesRuleProvider.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Text.RegularExpressions; +using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction; + +namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer; + +internal sealed class EnvironmentPropertiesRuleProvider : ISanitizationRuleProvider +{ + public IEnumerable GetRules() + { + List rules = []; + + var machine = Environment.MachineName; + if (!string.IsNullOrWhiteSpace(machine)) + { + var rx = new Regex(@"\b" + Regex.Escape(machine) + @"\b", SanitizerDefaults.DefaultOptions, TimeSpan.FromMilliseconds(SanitizerDefaults.DefaultMatchTimeoutMs)); + rules.Add(new(rx, "[MACHINE_NAME_REDACTED]", "Machine name")); + } + + var domain = Environment.UserDomainName; + if (!string.IsNullOrWhiteSpace(domain)) + { + var rx = new Regex(@"\b" + Regex.Escape(domain) + @"\b", SanitizerDefaults.DefaultOptions, TimeSpan.FromMilliseconds(SanitizerDefaults.DefaultMatchTimeoutMs)); + rules.Add(new(rx, "[USER_DOMAIN_NAME_REDACTED]", "User domain name")); + } + + return rules; + } +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/ErrorReportSanitizer.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/ErrorReportSanitizer.cs new file mode 100644 index 0000000000..35c4496b28 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/ErrorReportSanitizer.cs @@ -0,0 +1,85 @@ +ο»Ώ// 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.CmdPal.Core.Common.Services.Sanitizer.Abstraction; + +namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer; + +/// +/// Specific sanitizer used for error report content. Builds on top of the generic TextSanitizer. +/// +public sealed class ErrorReportSanitizer +{ + private readonly TextSanitizer _sanitizer = new(BuildProviders(), onGuardrailTriggered: OnGuardrailTriggered); + + private static void OnGuardrailTriggered(GuardrailEventArgs eventArgs) + { + var msg = $"Sanitization guardrail triggered for rule '{eventArgs.RuleDescription}': original length={eventArgs.OriginalLength}, result length={eventArgs.ResultLength}, ratio={eventArgs.Ratio:F2}, threshold={eventArgs.Threshold:F2}"; + CoreLogger.LogDebug(msg); + } + + private static IEnumerable BuildProviders() + { + // Order matters + return + [ + new PiiRuleProvider(), + new UrlRuleProvider(), + new NetworkRuleProvider(), + new TokenRuleProvider(), + new ConnectionStringRuleProvider(), + new SecretKeyValueRulesProvider(), + new EnvironmentPropertiesRuleProvider(), + new FilenameMaskRuleProvider(), + new ProfilePathAndUsernameRuleProvider() + ]; + } + + public string Sanitize(string? input) => _sanitizer.Sanitize(input); + + public string SanitizeException(Exception? exception) + { + if (exception is null) + { + return string.Empty; + } + + var fullMessage = GetFullExceptionMessage(exception); + return Sanitize(fullMessage); + } + + private static string GetFullExceptionMessage(Exception exception) + { + List messages = []; + var current = exception; + var depth = 0; + + // Prevent infinite loops on pathological InnerException graphs + while (current is not null && depth < 10) + { + messages.Add($"{current.GetType().Name}: {current.Message}"); + + if (!string.IsNullOrEmpty(current.StackTrace)) + { + messages.Add($"Stack Trace: {current.StackTrace}"); + } + + current = current.InnerException; + depth++; + } + + return string.Join(Environment.NewLine, messages); + } + + public void AddRule(string pattern, string replacement, string description = "") + => _sanitizer.AddRule(pattern, replacement, description); + + public void RemoveRule(string description) + => _sanitizer.RemoveRule(description); + + public IReadOnlyList GetRules() => _sanitizer.GetRules(); + + public string TestRule(string input, string ruleDescription) + => _sanitizer.TestRule(input, ruleDescription); +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/FilenameMaskRuleProvider.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/FilenameMaskRuleProvider.cs new file mode 100644 index 0000000000..5356ddd90d --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/FilenameMaskRuleProvider.cs @@ -0,0 +1,109 @@ +// 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.Frozen; +using System.Text.RegularExpressions; +using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction; + +namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer; + +internal sealed class FilenameMaskRuleProvider : ISanitizationRuleProvider +{ + private static readonly FrozenSet CommonFileStemExclusions = new[] + { + "settings", + "config", + "configuration", + "appsettings", + "options", + "prefs", + "preferences", + "squirrel", + "app", + "system", + "env", + "environment", + "manifest", + }.ToFrozenSet(StringComparer.OrdinalIgnoreCase); + + public IEnumerable GetRules() + { + const string pattern = """ + (? + (?: [A-Za-z]: )? (?: [\\/][^\\/:*?""<>|\s]+ )+ # drive-rooted or UNC-like + | [^\\/:*?""<>|\s]+ (?: [\\/][^\\/:*?""<>|\s]+ )+ # relative with at least one sep + ) + """; + + var rx = new Regex(pattern, SanitizerDefaults.DefaultOptions | RegexOptions.IgnorePatternWhitespace, TimeSpan.FromMilliseconds(SanitizerDefaults.DefaultMatchTimeoutMs)); + yield return new SanitizationRule(rx, MatchEvaluator, "Mask filename in any path"); + yield break; + + static string MatchEvaluator(Match m) + { + var full = m.Groups["full"].Value; + + var lastSep = Math.Max(full.LastIndexOf('\\'), full.LastIndexOf('/')); + if (lastSep < 0 || lastSep == full.Length - 1) + { + return full; + } + + var dir = full[..(lastSep + 1)]; + var file = full[(lastSep + 1)..]; + + var dot = file.LastIndexOf('.'); + var looksLikeFile = (dot > 0 && dot < file.Length - 1) || (file.StartsWith('.') && file.Length > 1); + + if (!looksLikeFile) + { + return full; + } + + string stem, ext; + if (dot > 0 && dot < file.Length - 1) + { + stem = file[..dot]; + ext = file[dot..]; + } + else + { + stem = file; + ext = string.Empty; + } + + if (!ShouldMaskFileName(stem)) + { + return dir + file; + } + + var masked = MaskStem(stem) + ext; + return dir + masked; + } + } + + private static string NormalizeStem(string stem) + { + return stem.Replace("-", string.Empty, StringComparison.Ordinal) + .Replace("_", string.Empty, StringComparison.Ordinal) + .Replace(".", string.Empty, StringComparison.Ordinal); + } + + private static bool ShouldMaskFileName(string stem) + { + return !CommonFileStemExclusions.Contains(NormalizeStem(stem)); + } + + private static string MaskStem(string stem) + { + if (string.IsNullOrEmpty(stem)) + { + return stem; + } + + var keep = Math.Min(2, stem.Length); + var maskedCount = Math.Max(1, stem.Length - keep); + return stem[..keep] + new string('*', maskedCount); + } +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/GuardrailEventArgs.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/GuardrailEventArgs.cs new file mode 100644 index 0000000000..ab00ac7510 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/GuardrailEventArgs.cs @@ -0,0 +1,14 @@ +// 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 Microsoft.CmdPal.Core.Common.Services.Sanitizer; + +public record GuardrailEventArgs( + string RuleDescription, + int OriginalLength, + int ResultLength, + double Threshold) +{ + public double Ratio => OriginalLength > 0 ? (double)ResultLength / OriginalLength : 1.0; +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/ISanitizationRuleProvider.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/ISanitizationRuleProvider.cs new file mode 100644 index 0000000000..5d21c5262f --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/ISanitizationRuleProvider.cs @@ -0,0 +1,12 @@ +// 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.CmdPal.Core.Common.Services.Sanitizer.Abstraction; + +namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer; + +internal interface ISanitizationRuleProvider +{ + IEnumerable GetRules(); +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/NetworkRuleProvider.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/NetworkRuleProvider.cs new file mode 100644 index 0000000000..4c352ff892 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/NetworkRuleProvider.cs @@ -0,0 +1,84 @@ +// 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.RegularExpressions; +using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction; + +namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer; + +internal sealed partial class NetworkRuleProvider : ISanitizationRuleProvider +{ + public IEnumerable GetRules() + { + yield return new(Ipv4Rx(), "[IP4_REDACTED]", "IP addresses"); + yield return new(Ipv6BracketedRx(), "[IP6_REDACTED]", "IPv6 addresses (bracketed/with port)"); + yield return new(Ipv6Rx(), "[IP6_REDACTED]", "IPv6 addresses"); + yield return new(MacAddressRx(), "[MAC_ADDRESS_REDACTED]", "MAC addresses"); + } + + [GeneratedRegex(@"\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b", + SanitizerDefaults.DefaultOptions, SanitizerDefaults.DefaultMatchTimeoutMs)] + private static partial Regex Ipv4Rx(); + + [GeneratedRegex( + """ + (?ix) # ignore case/whitespace + (?\d{1,5}) )? # optional port + """, + SanitizerDefaults.DefaultOptions | RegexOptions.IgnorePatternWhitespace, SanitizerDefaults.DefaultMatchTimeoutMs)] + private static partial Regex Ipv6BracketedRx(); + + [GeneratedRegex(@"\b(?:[0-9A-Fa-f]{2}[:-]){5}(?:[0-9A-Fa-f]{2}|[0-9A-Fa-f]{1,2})\b", + SanitizerDefaults.DefaultOptions, SanitizerDefaults.DefaultMatchTimeoutMs)] + private static partial Regex MacAddressRx(); +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/PiiRuleProvider.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/PiiRuleProvider.cs new file mode 100644 index 0000000000..964c6d83df --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/PiiRuleProvider.cs @@ -0,0 +1,83 @@ +// 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.RegularExpressions; +using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction; + +namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer; + +internal sealed partial class PiiRuleProvider : ISanitizationRuleProvider +{ + public IEnumerable GetRules() + { + yield return new(EmailRx(), "[EMAIL_REDACTED]", "Email addresses"); + yield return new(SsnRx(), "[SSN_REDACTED]", "Social Security Numbers"); + yield return new(CreditCardRx(), "[CARD_REDACTED]", "Credit card numbers"); + + // phone number regex is the most generic, so it goes last + // we can't make this too generic; otherwise we over-redact error codes, dates, etc. + yield return new(PhoneRx(), "[PHONE_REDACTED]", "Phone numbers"); + } + + [GeneratedRegex(@"\b[a-zA-Z0-9]([a-zA-Z0-9._%-]*[a-zA-Z0-9])?@[a-zA-Z0-9]([a-zA-Z0-9.-]*[a-zA-Z0-9])?\.[a-zA-Z]{2,}\b", + SanitizerDefaults.DefaultOptions, SanitizerDefaults.DefaultMatchTimeoutMs)] + private static partial Regex EmailRx(); + + [GeneratedRegex(""" + (?xi) + # ---------- boundaries ---------- + (? require separators between blocks (avoid plain big ints) + (?:\(\d{1,4}\)|\d{1,4}) + (?:[\p{Zs}.\-\/]+(?:\(\d{2,4}\)|\d{2,4})){1,6} + ) + + # ---------- optional extension ---------- + (?:[\p{Zs}.\-,:;]* (?:ext\.?|x) [\p{Zs}]* (?\d{1,6}))? + + (?!-\w) # don't end just before '-letter'/'-digit' + """, + SanitizerDefaults.DefaultOptions | RegexOptions.IgnorePatternWhitespace, SanitizerDefaults.DefaultMatchTimeoutMs)] + private static partial Regex PhoneRx(); + + [GeneratedRegex(@"\b\d{3}-\d{2}-\d{4}\b", + SanitizerDefaults.DefaultOptions, SanitizerDefaults.DefaultMatchTimeoutMs)] + private static partial Regex SsnRx(); + + [GeneratedRegex(@"\b\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b", + SanitizerDefaults.DefaultOptions, SanitizerDefaults.DefaultMatchTimeoutMs)] + private static partial Regex CreditCardRx(); +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/ProfilePathAndUsernameRuleProvider.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/ProfilePathAndUsernameRuleProvider.cs new file mode 100644 index 0000000000..be3d086ae7 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/ProfilePathAndUsernameRuleProvider.cs @@ -0,0 +1,155 @@ +// 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.Frozen; +using System.Text.RegularExpressions; +using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction; + +namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer; + +internal sealed class ProfilePathAndUsernameRuleProvider : ISanitizationRuleProvider +{ + private static readonly TimeSpan DefaultTimeout = TimeSpan.FromMilliseconds(SanitizerDefaults.DefaultMatchTimeoutMs); + + private readonly Dictionary _profilePaths = new(StringComparer.OrdinalIgnoreCase); + private readonly HashSet _usernames = new(StringComparer.OrdinalIgnoreCase); + + private static readonly FrozenSet CommonPathParts = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "Users", "home", "Documents", "Desktop", "AppData", "Local", "Roaming", + "Pictures", "Videos", "Music", "Downloads", "Program Files", "Windows", + "System32", "bin", "usr", "var", "etc", "opt", "tmp", + }.ToFrozenSet(StringComparer.OrdinalIgnoreCase); + + private static readonly FrozenSet CommonWords = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "admin", "user", "test", "guest", "public", "system", "service", + "default", "temp", "local", "shared", "common", "data", "config", + }.ToFrozenSet(StringComparer.OrdinalIgnoreCase); + + public ProfilePathAndUsernameRuleProvider() + { + DetectSystemPaths(); + } + + public IEnumerable GetRules() + { + List rules = []; + + // Profile path rules (ordered longest-first) + var orderedRules = _profilePaths + .Where(p => !string.IsNullOrEmpty(p.Key)) + .OrderByDescending(p => p.Key.Length); + + foreach (var profilePath in orderedRules) + { + try + { + var normalizedPath = profilePath.Key + .Replace('/', Path.DirectorySeparatorChar) + .Replace('\\', Path.DirectorySeparatorChar); + var escapedPath = Regex.Escape(normalizedPath); + + var pattern = escapedPath + @"(?:[/\\]*)"; + var rx = new Regex(pattern, SanitizerDefaults.DefaultOptions, DefaultTimeout); + + rules.Add(new(rx, profilePath.Value, $"Profile path: {profilePath}")); + } + catch + { + // Skip problematic paths + } + } + + // Username rules + foreach (var username in _usernames.Where(u => !string.IsNullOrEmpty(u) && u.Length > 2)) + { + try + { + if (!IsLikelyUsername(username)) + { + continue; + } + + var rx = new Regex(@"\b" + Regex.Escape(username) + @"\b", SanitizerDefaults.DefaultOptions, DefaultTimeout); + rules.Add(new(rx, "[USERNAME_REDACTED]", $"Username: {username}")); + } + catch + { + // Skip problematic usernames + } + } + + return rules; + } + + public IReadOnlyDictionary GetDetectedProfilePaths() => _profilePaths; + + public IReadOnlyCollection GetDetectedUsernames() => _usernames; + + private void DetectSystemPaths() + { + try + { + var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (!string.IsNullOrEmpty(userProfile) && Directory.Exists(userProfile)) + { + _profilePaths.Add(userProfile, "[USER_PROFILE_DIR]"); + var username = Path.GetFileName(userProfile); + if (!string.IsNullOrEmpty(username) && username.Length > 2) + { + _usernames.Add(username); + } + } + + Environment.SpecialFolder[] profileFolders = + [ + Environment.SpecialFolder.ApplicationData, + Environment.SpecialFolder.LocalApplicationData, + Environment.SpecialFolder.Desktop, + Environment.SpecialFolder.MyDocuments, + Environment.SpecialFolder.MyPictures, + Environment.SpecialFolder.MyVideos, + Environment.SpecialFolder.MyMusic, + Environment.SpecialFolder.StartMenu, + Environment.SpecialFolder.Startup, + Environment.SpecialFolder.DesktopDirectory + ]; + + foreach (var folder in profileFolders) + { + var dir = Environment.GetFolderPath(folder); + if (string.IsNullOrEmpty(dir)) + { + continue; + } + + var added = _profilePaths.TryAdd(dir, $"[{folder.ToString().ToUpperInvariant()}_DIR]"); + if (!added) + { + continue; + } + } + + string[] envVars = ["USERPROFILE", "HOME", "OneDrive", "OneDriveCommercial"]; + foreach (var envVar in envVars) + { + var envPath = Environment.GetEnvironmentVariable(envVar); + if (!string.IsNullOrEmpty(envPath) && Directory.Exists(envPath)) + { + _profilePaths.TryAdd(envPath, $"[{envVar.ToUpperInvariant()}_DIR]"); + } + } + } + catch (Exception ex) + { + CoreLogger.LogError("Error detecting system profile paths and usernames", ex); + } + } + + private static bool IsLikelyUsername(string username) => + !CommonWords.Contains(username) && + username.Length is >= 3 and <= 50 && + !username.All(char.IsDigit); +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/SanitizerDefaults.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/SanitizerDefaults.cs new file mode 100644 index 0000000000..83c7a9bbb1 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/SanitizerDefaults.cs @@ -0,0 +1,13 @@ +// 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.RegularExpressions; + +namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer; + +internal static class SanitizerDefaults +{ + public const RegexOptions DefaultOptions = RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled; + public const int DefaultMatchTimeoutMs = 100; +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/SecretKeyValueRulesProvider.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/SecretKeyValueRulesProvider.cs new file mode 100644 index 0000000000..d5b5f2358a --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/SecretKeyValueRulesProvider.cs @@ -0,0 +1,172 @@ +// 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.Frozen; +using System.Text.RegularExpressions; +using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction; + +namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer; + +internal sealed class SecretKeyValueRulesProvider : ISanitizationRuleProvider +{ + // Central list of common secret keys/phrases to redact when found in key=value pairs. + private static readonly FrozenSet SecretKeys = new HashSet(StringComparer.OrdinalIgnoreCase) + { + // Core passwords/secrets + "password", + "passphrase", + "passwd", + "pwd", + + // Tokens + "token", + "access token", + "refresh token", + "id token", + "auth token", + "session token", + "bearer token", + "personal access token", + "pat", + + // API / client credentials + "api key", + "api secret", + "x api key", + "client id", + "client secret", + "x client id", + "x client secret", + "consumer secret", + "service principal secret", + + // Cloud & platform (Azure/AppInsights/etc.) + "subscription key", + "instrumentation key", + "account key", + "storage account key", + "shared access key", + "shared access signature", + "SAS token", + + // Connection strings (often surfaced in exception messages) + "connection string", + "conn string", + "storage connection string", + + // Certificates & crypto + "private key", + "certificate password", + "client certificate password", + "pfx password", + + // AWS common keys + "aws access key id", + "aws secret access key", + "aws session token", + + // Optional service aliases + "cosmos db key", + }.ToFrozenSet(StringComparer.OrdinalIgnoreCase); + + public IEnumerable GetRules() + { + yield return BuildSecretKeyValueRule( + SecretKeys, + timeout: TimeSpan.FromSeconds(5), + starEverything: true); + } + + private static SanitizationRule BuildSecretKeyValueRule( + IEnumerable keys, + RegexOptions? options = null, + TimeSpan? timeout = null, + string label = "[REDACTED]", + bool treatDashUnderscoreAsSpace = true, + string separatorsClass = "[:=]", // char class for separators + string unquotedStopClass = "\\s", + bool starEverything = false) + { + ArgumentNullException.ThrowIfNull(keys); + + // Between-word matcher for keys: "api key" -> "api\s*key" (optionally treating _/- as "space") + var between = treatDashUnderscoreAsSpace ? @"(?:\s|[_-])*" : @"\s*"; + + var patterns = new List(); + + foreach (var raw in keys) + { + var key = raw?.Trim(); + if (string.IsNullOrEmpty(key)) + { + continue; + } + + if (starEverything && key is not ['*', ..]) + { + key = "*" + key; + } + + if (key is ['*', .. var tail]) + { + // Wildcard prefix: allow one non-space token + optional "-" or "_" before the remainder. + // Matches: "api key", "api-key", "azure-api-key", "user_api_key" + var remainder = tail.Trim(); + if (remainder.Length == 0) + { + continue; + } + + var rem = Normalize(remainder, between); + patterns.Add($@"(?:(?>[A-Za-z0-9_]{{1,128}}[_-]))?{rem}"); + } + else + { + patterns.Add(Normalize(key, between)); + } + } + + if (patterns.Count == 0) + { + throw new ArgumentException("No non-empty keys provided.", nameof(keys)); + } + + var keysAlt = string.Join("|", patterns); + + var pattern = + $""" + # Negative lookbehind to ensure the key is not part of a larger word + (?(?:{keysAlt})) + # Negative lookahead to ensure the key is not part of a larger word + (?![A-Za-z0-9]) + # Optional whitespace between key and separator + \s* + # Separator (e.g., ':' or '=') + (?{separatorsClass}) + # Optional whitespace after separator + \s* + # Match and capture the value, supporting quoted or unquoted values + (?: + # Quoted value: match opening quote, value, and closing quote + (?["'])(?[^"']+)\k + | + # Unquoted value: match up to the next whitespace + (?[^{unquotedStopClass}]+) + ) + """; + + var rx = new Regex( + pattern, + (options ?? (RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)) | RegexOptions.IgnorePatternWhitespace, + timeout ?? TimeSpan.FromMilliseconds(1000)); + + var replacement = @"${key}${sep} ${q}" + label + @"${q}"; + return new SanitizationRule(rx, replacement, "Sensitive key/value pairs"); + + static string Normalize(string s, string betweenSep) + => Regex.Escape(s).Replace("\\ ", betweenSep); + } +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/TextSanitizer.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/TextSanitizer.cs new file mode 100644 index 0000000000..7b835bc26f --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/TextSanitizer.cs @@ -0,0 +1,135 @@ +// 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.RegularExpressions; +using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction; + +namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer; + +/// +/// Generic text sanitizer that applies a sequence of regex-based rules over input text. +/// +internal sealed class TextSanitizer : ITextSanitizer +{ + // Default guardrail: sanitized text must retain at least 30% of the original length + private const double DefaultGuardrailThreshold = 0.3; + private static readonly TimeSpan DefaultTimeout = TimeSpan.FromMilliseconds(SanitizerDefaults.DefaultMatchTimeoutMs); + + private readonly List _rules = []; + private readonly double _guardrailThreshold; + private readonly Action? _onGuardrailTriggered; + + public TextSanitizer( + double guardrailThreshold = DefaultGuardrailThreshold, + Action? onGuardrailTriggered = null) + { + _guardrailThreshold = guardrailThreshold; + _onGuardrailTriggered = onGuardrailTriggered; + } + + public TextSanitizer( + IEnumerable providers, + double guardrailThreshold = DefaultGuardrailThreshold, + Action? onGuardrailTriggered = null) + { + ArgumentNullException.ThrowIfNull(providers); + _guardrailThreshold = guardrailThreshold; + _onGuardrailTriggered = onGuardrailTriggered; + + foreach (var p in providers) + { + try + { + _rules.AddRange(p.GetRules()); + } + catch + { + // Best-effort; ignore provider errors + } + } + } + + public string Sanitize(string? input) + { + if (string.IsNullOrEmpty(input)) + { + return input ?? string.Empty; + } + + var result = input; + + foreach (var rule in _rules) + { + try + { + var previous = result; + + result = rule.Evaluator is null + ? rule.Regex.Replace(previous, rule.Replacement!) + : rule.Regex.Replace(previous, rule.Evaluator); + + if (result.Length < previous.Length * _guardrailThreshold) + { + _onGuardrailTriggered?.Invoke(new GuardrailEventArgs( + rule.Description, + previous.Length, + result.Length, + _guardrailThreshold)); + result = previous; // Guardrail + } + } + catch (RegexMatchTimeoutException) + { + // Ignore timeouts; keep the original input + } + catch + { + // Ignore other exceptions; keep the original input + } + } + + return result; + } + + public void AddRule(string pattern, string replacement, string description = "") + { + var rx = new Regex(pattern, SanitizerDefaults.DefaultOptions, DefaultTimeout); + _rules.Add(new SanitizationRule(rx, replacement, description)); + } + + public void RemoveRule(string description) + { + _rules.RemoveAll(r => r.Description.Equals(description, StringComparison.OrdinalIgnoreCase)); + } + + public IReadOnlyList GetRules() => _rules.AsReadOnly(); + + public string TestRule(string input, string ruleDescription) + { + var rule = _rules.FirstOrDefault(r => r.Description.Contains(ruleDescription, StringComparison.OrdinalIgnoreCase)); + if (rule.Regex is null) + { + return input; + } + + try + { + if (rule.Evaluator is not null) + { + return rule.Regex.Replace(input, rule.Evaluator); + } + + if (rule.Replacement is not null) + { + return rule.Regex.Replace(input, rule.Replacement); + } + } + catch + { + // Ignore exceptions; return original input + } + + return input; + } +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/TokenRuleProvider.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/TokenRuleProvider.cs new file mode 100644 index 0000000000..fb8da33336 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/TokenRuleProvider.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Text.RegularExpressions; +using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction; + +namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer; + +internal sealed partial class TokenRuleProvider : ISanitizationRuleProvider +{ + public IEnumerable GetRules() + { + yield return new(JwtRx(), "[JWT_REDACTED]", "JSON Web Tokens (JWT)"); + yield return new(TokenRx(), "[TOKEN_REDACTED]", "Potential API keys/tokens"); + } + + [GeneratedRegex(@"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b", + SanitizerDefaults.DefaultOptions, SanitizerDefaults.DefaultMatchTimeoutMs)] + private static partial Regex JwtRx(); + + [GeneratedRegex(@"\b[A-Za-z0-9]{32,128}\b", + SanitizerDefaults.DefaultOptions, SanitizerDefaults.DefaultMatchTimeoutMs)] + private static partial Regex TokenRx(); +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/UrlRuleProvider.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/UrlRuleProvider.cs new file mode 100644 index 0000000000..17ded73ea5 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/UrlRuleProvider.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Text.RegularExpressions; +using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction; + +namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer; + +internal sealed partial class UrlRuleProvider : ISanitizationRuleProvider +{ + public IEnumerable GetRules() + { + yield return new(UrlRx(), "[URL_REDACTED]", "URLs"); + } + + [GeneratedRegex(@"\b(?:https?|ftp|ftps|file|jdbc|ldap|mailto)://[^\s<>""'{}\[\]\\^`|]+", + SanitizerDefaults.DefaultOptions, SanitizerDefaults.DefaultMatchTimeoutMs)] + private static partial Regex UrlRx(); +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/BatchUpdateManager.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/BatchUpdateManager.cs new file mode 100644 index 0000000000..52d6091bd8 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/BatchUpdateManager.cs @@ -0,0 +1,136 @@ +ο»Ώ// 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.Concurrent; +using Microsoft.CmdPal.Core.Common; +using Microsoft.CmdPal.Core.Common.Helpers; + +namespace Microsoft.CmdPal.Core.ViewModels; + +internal static class BatchUpdateManager +{ + private const int ExpectedBatchSize = 32; + + // 30 ms chosen empirically to balance responsiveness and batching: + // - Keeps perceived latency low (< ~50 ms) for user-visible updates. + // - Still allows multiple COM/background events to be coalesced into a single batch. + private static readonly TimeSpan BatchDelay = TimeSpan.FromMilliseconds(30); + private static readonly ConcurrentQueue DirtyQueue = []; + private static readonly Timer Timer = new(static _ => Flush(), null, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); + + private static InterlockedBoolean _isFlushScheduled; + + /// + /// Enqueue a target for batched processing. Safe to call from any thread (including COM callbacks). + /// + public static void Queue(IBatchUpdateTarget target) + { + if (!target.TryMarkBatchQueued()) + { + return; // already queued in current batch window + } + + DirtyQueue.Enqueue(target); + TryScheduleFlush(); + } + + private static void TryScheduleFlush() + { + if (!_isFlushScheduled.Set()) + { + return; + } + + if (DirtyQueue.IsEmpty) + { + _isFlushScheduled.Clear(); + + if (DirtyQueue.IsEmpty) + { + return; + } + + if (!_isFlushScheduled.Set()) + { + return; + } + } + + try + { + Timer.Change(BatchDelay, Timeout.InfiniteTimeSpan); + } + catch (Exception ex) + { + _isFlushScheduled.Clear(); + CoreLogger.LogError("Failed to arm batch timer.", ex); + } + } + + private static void Flush() + { + try + { + var drained = new List(ExpectedBatchSize); + while (DirtyQueue.TryDequeue(out var item)) + { + drained.Add(item); + } + + if (drained.Count == 0) + { + return; + } + + // LOAD BEARING: + // ApplyPendingUpdates must run on a background thread. + // The VM itself is responsible for marshaling UI notifications to its _uiScheduler. + ApplyBatch(drained); + } + catch (Exception ex) + { + // Don't kill the timer thread. + CoreLogger.LogError("Batch flush failed.", ex); + } + finally + { + _isFlushScheduled.Clear(); + TryScheduleFlush(); + } + } + + private static void ApplyBatch(List items) + { + // Runs on the Timer callback thread (ThreadPool). That's fine: background work only. + foreach (var item in items) + { + // Allow re-queueing immediately if more COM events arrive during apply. + item.ClearBatchQueued(); + + try + { + item.ApplyPendingUpdates(); + } + catch (Exception ex) + { + CoreLogger.LogError("Failed to apply pending updates for a batched target.", ex); + } + } + } +} + +internal interface IBatchUpdateTarget +{ + /// UI scheduler (used by targets internally for UI marshaling). Kept here for diagnostics / consistency. + TaskScheduler UIScheduler { get; } + + /// Apply any coalesced updates. Must be safe to call on a background thread. + void ApplyPendingUpdates(); + + /// De-dupe gate: returns true only for the first enqueue until cleared. + bool TryMarkBatchQueued(); + + /// Clear the de-dupe gate so the item can be queued again. + void ClearBatchQueued(); +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ContextMenuViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ContextMenuViewModel.cs index 0263f20464..07c238ab42 100644 --- a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ContextMenuViewModel.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ContextMenuViewModel.cs @@ -53,7 +53,7 @@ public partial class ContextMenuViewModel : ObservableObject, { if (SelectedItem is not null) { - if (SelectedItem.MoreCommands.Count() > 1) + if (SelectedItem.PrimaryCommand is not null || SelectedItem.HasMoreCommands) { ContextMenuStack.Clear(); PushContextStack(SelectedItem.AllCommands); diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ExtensionObjectViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ExtensionObjectViewModel.cs index e7bed46db6..53af586431 100644 --- a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ExtensionObjectViewModel.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ExtensionObjectViewModel.cs @@ -2,36 +2,99 @@ // 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.Buffers; +using System.Collections.Concurrent; +using System.ComponentModel; +using System.Runtime.CompilerServices; using CommunityToolkit.Mvvm.ComponentModel; using Microsoft.CmdPal.Core.Common; +using Microsoft.CmdPal.Core.Common.Helpers; namespace Microsoft.CmdPal.Core.ViewModels; -public abstract partial class ExtensionObjectViewModel : ObservableObject +public abstract partial class ExtensionObjectViewModel : ObservableObject, IBatchUpdateTarget, IBackgroundPropertyChangedNotification { - public WeakReference PageContext { get; set; } + private const int InitialPropertyBatchingBufferSize = 16; - internal ExtensionObjectViewModel(IPageContext? context) - { - var realContext = context ?? (this is IPageContext c ? c : throw new ArgumentException("You need to pass in an IErrorContext")); - PageContext = new(realContext); - } + // Raised on the background thread before UI notifications. It's raised on the background thread to prevent + // blocking the COM proxy. + public event PropertyChangedEventHandler? PropertyChangedBackground; - internal ExtensionObjectViewModel(WeakReference context) - { - PageContext = context; - } + private readonly ConcurrentQueue _pendingProps = []; - public async virtual Task InitializePropertiesAsync() + private readonly TaskScheduler _uiScheduler; + + private InterlockedBoolean _batchQueued; + + public WeakReference PageContext { get; private set; } = null!; + + TaskScheduler IBatchUpdateTarget.UIScheduler => _uiScheduler; + + void IBatchUpdateTarget.ApplyPendingUpdates() => ApplyPendingUpdates(); + + bool IBatchUpdateTarget.TryMarkBatchQueued() => _batchQueued.Set(); + + void IBatchUpdateTarget.ClearBatchQueued() => _batchQueued.Clear(); + + private protected ExtensionObjectViewModel(TaskScheduler scheduler) { - var t = new Task(() => + if (this is not IPageContext) { - SafeInitializePropertiesSynchronous(); - }); - t.Start(); - await t; + throw new InvalidOperationException($"Constructor overload without IPageContext can only be used when the derived class implements IPageContext. Type: {GetType().FullName}"); + } + + _uiScheduler = scheduler ?? throw new ArgumentNullException(nameof(scheduler)); + + // Defer PageContext assignment - derived constructor MUST call InitializePageContext() + // or we set it lazily on first access } + private protected ExtensionObjectViewModel(IPageContext context) + { + ArgumentNullException.ThrowIfNull(context); + + PageContext = new WeakReference(context); + _uiScheduler = context.Scheduler; + + LogIfDefaultScheduler(); + } + + private protected ExtensionObjectViewModel(WeakReference contextRef) + { + ArgumentNullException.ThrowIfNull(contextRef); + + if (!contextRef.TryGetTarget(out var context)) + { + throw new ArgumentException("IPageContext must be alive when creating view models.", nameof(contextRef)); + } + + PageContext = contextRef; + _uiScheduler = context.Scheduler; + + LogIfDefaultScheduler(); + } + + protected void InitializeSelfAsPageContext() + { + if (this is not IPageContext self) + { + throw new InvalidOperationException("This method can only be called when the class implements IPageContext."); + } + + PageContext = new WeakReference(self); + } + + private void LogIfDefaultScheduler() + { + if (_uiScheduler == TaskScheduler.Default) + { + CoreLogger.LogDebug($"ExtensionObjectViewModel created with TaskScheduler.Default. Type: {GetType().FullName}"); + } + } + + public virtual Task InitializePropertiesAsync() + => Task.Run(SafeInitializePropertiesSynchronous); + public void SafeInitializePropertiesSynchronous() { try @@ -46,49 +109,151 @@ public abstract partial class ExtensionObjectViewModel : ObservableObject public abstract void InitializeProperties(); - protected void UpdateProperty(string propertyName) - { - DoOnUiThread(() => OnPropertyChanged(propertyName)); - } + protected void UpdateProperty(string propertyName) => MarkPropertyDirty(propertyName); protected void UpdateProperty(string propertyName1, string propertyName2) { - DoOnUiThread(() => - { - OnPropertyChanged(propertyName1); - OnPropertyChanged(propertyName2); - }); - } - - protected void UpdateProperty(string propertyName1, string propertyName2, string propertyName3) - { - DoOnUiThread(() => - { - OnPropertyChanged(propertyName1); - OnPropertyChanged(propertyName2); - OnPropertyChanged(propertyName3); - }); + MarkPropertyDirty(propertyName1); + MarkPropertyDirty(propertyName2); } protected void UpdateProperty(params string[] propertyNames) { - DoOnUiThread(() => + foreach (var p in propertyNames) { - foreach (var propertyName in propertyNames) - { - OnPropertyChanged(propertyName); - } - }); + MarkPropertyDirty(p); + } } + internal void MarkPropertyDirty(string? propertyName) + { + if (string.IsNullOrEmpty(propertyName)) + { + return; + } + + // We should re-consider if this worth deduping + _pendingProps.Enqueue(propertyName); + BatchUpdateManager.Queue(this); + } + + public void ApplyPendingUpdates() + { + ((IBatchUpdateTarget)this).ClearBatchQueued(); + + var buffer = ArrayPool.Shared.Rent(InitialPropertyBatchingBufferSize); + var count = 0; + var transferred = false; + + try + { + while (_pendingProps.TryDequeue(out var name)) + { + if (count == buffer.Length) + { + var bigger = ArrayPool.Shared.Rent(buffer.Length * 2); + Array.Copy(buffer, bigger, buffer.Length); + ArrayPool.Shared.Return(buffer, clearArray: true); + buffer = bigger; + } + + buffer[count++] = name; + } + + if (count == 0) + { + return; + } + + // 1) Background subscribers (must be raised before UI notifications). + var propertyChangedEventHandler = PropertyChangedBackground; + if (propertyChangedEventHandler is not null) + { + RaiseBackground(propertyChangedEventHandler, this, buffer, count); + } + + // 2) UI-facing PropertyChanged: ALWAYS marshal to UI scheduler. + // Hand-off pooled buffer to UI task (UI task returns it). + // + // It would be lovely to do nothing if no one is actually listening on PropertyChanged, + // but ObservableObject doesn't expose that information. + _ = Task.Factory.StartNew( + static state => + { + var p = (UiBatch)state!; + try + { + p.Owner.RaiseUi(p.Names, p.Count); + } + catch (Exception ex) + { + CoreLogger.LogError("Failed to raise property change notifications on UI thread.", ex); + } + finally + { + ArrayPool.Shared.Return(p.Names, clearArray: true); + } + }, + new UiBatch(this, buffer, count), + CancellationToken.None, + TaskCreationOptions.DenyChildAttach, + _uiScheduler); + + transferred = true; + } + catch (Exception ex) + { + CoreLogger.LogError("Failed to apply pending property updates.", ex); + } + finally + { + if (!transferred) + { + ArrayPool.Shared.Return(buffer, clearArray: true); + } + } + } + + private void RaiseUi(string[] names, int count) + { + for (var i = 0; i < count; i++) + { + OnPropertyChanged(Args(names[i])); + } + } + + private static void RaiseBackground(PropertyChangedEventHandler handlers, object sender, string[] names, int count) + { + try + { + for (var i = 0; i < count; i++) + { + handlers(sender, Args(names[i])); + } + } + catch (Exception ex) + { + CoreLogger.LogError("Failed to raise PropertyChangedBackground notifications.", ex); + } + } + + private sealed record UiBatch(ExtensionObjectViewModel Owner, string[] Names, int Count); + protected void ShowException(Exception ex, string? extensionHint = null) { if (PageContext.TryGetTarget(out var pageContext)) { pageContext.ShowException(ex, extensionHint); } + else + { + CoreLogger.LogError("Failed to show exception because PageContext is no longer available.", ex); + } } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static PropertyChangedEventArgs Args(string name) => new(name); + protected void DoOnUiThread(Action action) { if (PageContext.TryGetTarget(out var pageContext)) diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/IBackgroundPropertyChangedNotification.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/IBackgroundPropertyChangedNotification.cs new file mode 100644 index 0000000000..4db157f46d --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/IBackgroundPropertyChangedNotification.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.ComponentModel; + +namespace Microsoft.CmdPal.Core.ViewModels; + +/// +/// Provides a notification mechanism for property changes that fires +/// synchronously on the calling thread. +/// +public interface IBackgroundPropertyChangedNotification +{ + /// + /// Occurs when the value of a property changes. + /// + event PropertyChangedEventHandler? PropertyChangedBackground; +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Microsoft.CmdPal.Core.ViewModels.csproj b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Microsoft.CmdPal.Core.ViewModels.csproj index 4ace6c5783..6e1b224ecd 100644 --- a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Microsoft.CmdPal.Core.ViewModels.csproj +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Microsoft.CmdPal.Core.ViewModels.csproj @@ -1,5 +1,9 @@ ο»Ώ + + + false + diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/PageViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/PageViewModel.cs index 2a82f80a02..3b08b9266b 100644 --- a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/PageViewModel.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/PageViewModel.cs @@ -77,11 +77,11 @@ public partial class PageViewModel : ExtensionObjectViewModel, IPageContext public IconInfoViewModel Icon { get; protected set; } public PageViewModel(IPage? model, TaskScheduler scheduler, AppExtensionHost extensionHost) - : base((IPageContext?)null) + : base(scheduler) { + InitializeSelfAsPageContext(); _pageModel = new(model); Scheduler = scheduler; - PageContext = new(this); ExtensionHost = extensionHost; Icon = new(null); diff --git a/src/modules/cmdpal/CoreCommonProps.props b/src/modules/cmdpal/CoreCommonProps.props index 438d044e2a..51d502a65d 100644 --- a/src/modules/cmdpal/CoreCommonProps.props +++ b/src/modules/cmdpal/CoreCommonProps.props @@ -43,4 +43,10 @@ + + + <_Parameter1>$(AssemblyName).UnitTests + + + diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Ext.PowerToys.slnf b/src/modules/cmdpal/Microsoft.CmdPal.Ext.PowerToys.slnf new file mode 100644 index 0000000000..49518dd33a --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.Ext.PowerToys.slnf @@ -0,0 +1,26 @@ +{ + "solution": { + "path": "..\\..\\..\\PowerToys.slnx", + "projects": [ + "src\\common\\Common.Search\\Common.Search.csproj", + "src\\common\\Common.UI\\Common.UI.csproj", + "src\\common\\ManagedCommon\\ManagedCommon.csproj", + "src\\common\\ManagedTelemetry\\Telemetry\\ManagedTelemetry.csproj", + "src\\common\\PowerToys.ModuleContracts\\PowerToys.ModuleContracts.csproj", + "src\\common\\SettingsAPI\\SettingsAPI.vcxproj", + "src\\common\\interop\\PowerToys.Interop.vcxproj", + "src\\common\\logger\\logger.vcxproj", + "src\\common\\version\\version.vcxproj", + "src\\logging\\logging.vcxproj", + "src\\modules\\MouseUtils\\MouseJump.Common\\MouseJump.Common.csproj", + "src\\modules\\Workspaces\\Workspaces.ModuleServices\\Workspaces.ModuleServices.csproj", + "src\\modules\\Workspaces\\WorkspacesCsharpLibrary\\WorkspacesCsharpLibrary.csproj", + "src\\modules\\ZoomIt\\ZoomItSettingsInterop\\ZoomItSettingsInterop.vcxproj", + "src\\modules\\awake\\Awake.ModuleServices\\Awake.ModuleServices.csproj", + "src\\modules\\cmdpal\\ext\\Microsoft.CmdPal.Ext.PowerToys\\Microsoft.CmdPal.Ext.PowerToys.csproj", + "src\\modules\\colorPicker\\ColorPicker.ModuleServices\\ColorPicker.ModuleServices.csproj", + "src\\modules\\fancyzones\\FancyZonesEditorCommon\\FancyZonesEditorCommon.csproj", + "src\\settings-ui\\Settings.UI.Library\\Settings.UI.Library.csproj" + ] + } +} \ No newline at end of file diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AliasManager.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AliasManager.cs index 72a295c83f..4c997266c6 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AliasManager.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AliasManager.cs @@ -107,7 +107,7 @@ public partial class AliasManager : ObservableObject } // Look for the alias belonging to another command, and remove it - if (newAlias is not null && kv.Value.Alias == newAlias.Alias) + if (newAlias is not null && kv.Value.Alias == newAlias.Alias && kv.Value.CommandId != commandId) { toRemove.Add(kv.Value); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandProviderWrapper.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandProviderWrapper.cs index 939b42de14..d0f73f4a12 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandProviderWrapper.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandProviderWrapper.cs @@ -6,6 +6,7 @@ using ManagedCommon; using Microsoft.CmdPal.Core.Common.Services; using Microsoft.CmdPal.Core.ViewModels; using Microsoft.CmdPal.Core.ViewModels.Models; +using Microsoft.CmdPal.UI.ViewModels.Services; using Microsoft.CommandPalette.Extensions; using Microsoft.Extensions.DependencyInjection; @@ -23,6 +24,8 @@ public sealed class CommandProviderWrapper private readonly TaskScheduler _taskScheduler; + private readonly ICommandProviderCache? _commandProviderCache; + public TopLevelViewModel[] TopLevelItems { get; private set; } = []; public TopLevelViewModel[] FallbackItems { get; private set; } = []; @@ -43,13 +46,7 @@ public sealed class CommandProviderWrapper public bool IsActive { get; private set; } - public string ProviderId - { - get - { - return string.IsNullOrEmpty(Extension?.ExtensionUniqueId) ? Id : Extension.ExtensionUniqueId; - } - } + public string ProviderId => string.IsNullOrEmpty(Extension?.ExtensionUniqueId) ? Id : Extension.ExtensionUniqueId; public CommandProviderWrapper(ICommandProvider provider, TaskScheduler mainThread) { @@ -77,9 +74,11 @@ public sealed class CommandProviderWrapper Logger.LogDebug($"Initialized command provider {ProviderId}"); } - public CommandProviderWrapper(IExtensionWrapper extension, TaskScheduler mainThread) + public CommandProviderWrapper(IExtensionWrapper extension, TaskScheduler mainThread, ICommandProviderCache commandProviderCache) { _taskScheduler = mainThread; + _commandProviderCache = commandProviderCache; + Extension = extension; ExtensionHost = new CommandPaletteHost(extension); if (!Extension.IsRunning()) @@ -128,30 +127,31 @@ public sealed class CommandProviderWrapper if (!isValid) { IsActive = false; + RecallFromCache(); return; } var settings = serviceProvider.GetService()!; - IsActive = GetProviderSettings(settings).IsEnabled; + var providerSettings = GetProviderSettings(settings); + IsActive = providerSettings.IsEnabled; if (!IsActive) { + RecallFromCache(); return; } - ICommandItem[]? commands = null; - IFallbackCommandItem[]? fallbacks = null; - + var displayInfoInitialized = false; try { var model = _commandProvider.Unsafe!; - Task t = new(model.TopLevelCommands); - t.Start(); - commands = await t.ConfigureAwait(false); + Task loadTopLevelCommandsTask = new(model.TopLevelCommands); + loadTopLevelCommandsTask.Start(); + var commands = await loadTopLevelCommandsTask.ConfigureAwait(false); // On a BG thread here - fallbacks = model.FallbackCommands(); + var fallbacks = model.FallbackCommands(); if (model is ICommandProvider2 two) { @@ -162,6 +162,13 @@ public sealed class CommandProviderWrapper DisplayName = model.DisplayName; Icon = new(model.Icon); Icon.InitializeProperties(); + displayInfoInitialized = true; + + // Update cached display name + if (_commandProviderCache is not null && Extension?.ExtensionUniqueId is not null) + { + _commandProviderCache.Memorize(Extension.ExtensionUniqueId, new CommandProviderCacheItem(model.DisplayName)); + } // Note: explicitly not InitializeProperties()ing the settings here. If // we do that, then we'd regress GH #38321 @@ -177,6 +184,25 @@ public sealed class CommandProviderWrapper Logger.LogError("Failed to load commands from extension"); Logger.LogError($"Extension was {Extension!.PackageFamilyName}"); Logger.LogError(e.ToString()); + + if (!displayInfoInitialized) + { + RecallFromCache(); + } + } + } + + private void RecallFromCache() + { + var cached = _commandProviderCache?.Recall(ProviderId); + if (cached is not null) + { + DisplayName = cached.DisplayName; + } + + if (string.IsNullOrWhiteSpace(DisplayName)) + { + DisplayName = Extension?.PackageDisplayName ?? Extension?.PackageFamilyName ?? ProviderId; } } @@ -185,7 +211,7 @@ public sealed class CommandProviderWrapper var settings = serviceProvider.GetService()!; var providerSettings = GetProviderSettings(settings); - Func makeAndAdd = (ICommandItem? i, bool fallback) => + var makeAndAdd = (ICommandItem? i, bool fallback) => { CommandItemViewModel commandItemViewModel = new(new(i), pageContext); TopLevelViewModel topLevelViewModel = new(commandItemViewModel, fallback, ExtensionHost, ProviderId, settings, providerSettings, serviceProvider, i); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/OpenSettingsCommand.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/OpenSettingsCommand.cs index ac7fe624e5..f0900ec72e 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/OpenSettingsCommand.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/OpenSettingsCommand.cs @@ -19,7 +19,7 @@ public partial class OpenSettingsCommand : InvokableCommand public override ICommandResult Invoke() { - WeakReferenceMessenger.Default.Send(); + WeakReferenceMessenger.Default.Send(new OpenSettingsMessage()); return CommandResult.KeepOpen(); } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/OpenSettingsMessage.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/OpenSettingsMessage.cs index c699ab427a..54909710a5 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/OpenSettingsMessage.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/OpenSettingsMessage.cs @@ -4,6 +4,4 @@ namespace Microsoft.CmdPal.UI.Messages; -public record OpenSettingsMessage() -{ -} +public record OpenSettingsMessage(string SettingsPageTag = ""); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ProviderSettingsViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ProviderSettingsViewModel.cs index 68e554e463..d6208b712a 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ProviderSettingsViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ProviderSettingsViewModel.cs @@ -14,11 +14,13 @@ namespace Microsoft.CmdPal.UI.ViewModels; public partial class ProviderSettingsViewModel : ObservableObject { + private static readonly IconInfoViewModel EmptyIcon = new(null); + private readonly CommandProviderWrapper _provider; private readonly ProviderSettings _providerSettings; private readonly SettingsModel _settings; - private readonly Lock _initializeSettingsLock = new(); + private Task? _initializeSettingsTask; public ProviderSettingsViewModel( @@ -43,7 +45,7 @@ public partial class ProviderSettingsViewModel : ObservableObject HasFallbackCommands ? $"{ExtensionName}, {TopLevelCommands.Count} commands, {_provider.FallbackItems?.Length} fallback commands" : $"{ExtensionName}, {TopLevelCommands.Count} commands" : - Resources.builtin_disabled_extension; + $"{ExtensionName}, {Resources.builtin_disabled_extension}"; [MemberNotNullWhen(true, nameof(Extension))] public bool IsFromExtension => _provider.Extension is not null; @@ -52,7 +54,7 @@ public partial class ProviderSettingsViewModel : ObservableObject public string ExtensionVersion => IsFromExtension ? $"{Extension.Version.Major}.{Extension.Version.Minor}.{Extension.Version.Build}.{Extension.Version.Revision}" : string.Empty; - public IconInfoViewModel Icon => _provider.Icon; + public IconInfoViewModel Icon => IsEnabled ? _provider.Icon : EmptyIcon; [ObservableProperty] public partial bool LoadingSettings { get; set; } @@ -69,6 +71,7 @@ public partial class ProviderSettingsViewModel : ObservableObject WeakReferenceMessenger.Default.Send(new()); OnPropertyChanged(nameof(IsEnabled)); OnPropertyChanged(nameof(ExtensionSubtext)); + OnPropertyChanged(nameof(Icon)); } if (value == true) diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/CommandProviderCacheContainer.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/CommandProviderCacheContainer.cs new file mode 100644 index 0000000000..0ab835ebb9 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/CommandProviderCacheContainer.cs @@ -0,0 +1,10 @@ +ο»Ώ// 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 Microsoft.CmdPal.UI.ViewModels.Services; + +internal sealed class CommandProviderCacheContainer +{ + public Dictionary Cache { get; init; } = new(StringComparer.Ordinal); +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/CommandProviderCacheItem.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/CommandProviderCacheItem.cs new file mode 100644 index 0000000000..8c86c28178 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/CommandProviderCacheItem.cs @@ -0,0 +1,7 @@ +ο»Ώ// 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 Microsoft.CmdPal.UI.ViewModels.Services; + +public record CommandProviderCacheItem(string DisplayName); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/CommandProviderCacheSerializationContext.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/CommandProviderCacheSerializationContext.cs new file mode 100644 index 0000000000..9b73cdd19d --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/CommandProviderCacheSerializationContext.cs @@ -0,0 +1,13 @@ +ο»Ώ// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Text.Json.Serialization; + +namespace Microsoft.CmdPal.UI.ViewModels.Services; + +[JsonSerializable(typeof(CommandProviderCacheItem))] +[JsonSerializable(typeof(Dictionary))] +[JsonSerializable(typeof(CommandProviderCacheContainer))] +[JsonSourceGenerationOptions(WriteIndented = true, PropertyNameCaseInsensitive = false)] +internal sealed partial class CommandProviderCacheSerializationContext : JsonSerializerContext; diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/DefaultCommandProviderCache.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/DefaultCommandProviderCache.cs new file mode 100644 index 0000000000..5e8f790b12 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/DefaultCommandProviderCache.cs @@ -0,0 +1,127 @@ +ο»Ώ// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Text.Json; +using ManagedCommon; +using Microsoft.CmdPal.Core.Common.Helpers; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.UI.ViewModels.Services; + +public sealed partial class DefaultCommandProviderCache : ICommandProviderCache, IDisposable +{ + private const string CacheFileName = "commandProviderCache.json"; + + private readonly Dictionary _cache = new(StringComparer.Ordinal); + + private readonly Lock _sync = new(); + + private readonly SupersedingAsyncGate _saveGate; + + public DefaultCommandProviderCache() + { + _saveGate = new SupersedingAsyncGate(async _ => await TrySaveAsync().ConfigureAwait(false)); + TryLoad(); + } + + public void Memorize(string providerId, CommandProviderCacheItem item) + { + ArgumentNullException.ThrowIfNull(providerId); + + lock (_sync) + { + _cache[providerId] = item; + } + + _ = _saveGate.ExecuteAsync(); + } + + public CommandProviderCacheItem? Recall(string providerId) + { + ArgumentNullException.ThrowIfNull(providerId); + + lock (_sync) + { + _cache.TryGetValue(providerId, out var item); + return item; + } + } + + private static string GetCacheFilePath() + { + var directory = Utilities.BaseSettingsPath("Microsoft.CmdPal"); + Directory.CreateDirectory(directory); + return Path.Combine(directory, CacheFileName); + } + + private void TryLoad() + { + try + { + var path = GetCacheFilePath(); + if (!File.Exists(path)) + { + return; + } + + var json = File.ReadAllText(path); + if (string.IsNullOrWhiteSpace(json)) + { + return; + } + + var loaded = JsonSerializer.Deserialize( + json, + CommandProviderCacheSerializationContext.Default.CommandProviderCacheContainer!); + if (loaded?.Cache is null) + { + return; + } + + _cache.Clear(); + foreach (var kvp in loaded.Cache) + { + if (!string.IsNullOrEmpty(kvp.Key) && kvp.Value is not null) + { + _cache[kvp.Key] = kvp.Value; + } + } + } + catch (Exception ex) + { + Logger.LogError("Failed to load command provider cache: ", ex); + } + } + + private async Task TrySaveAsync() + { + try + { + Dictionary snapshot; + lock (_sync) + { + snapshot = new Dictionary(_cache, StringComparer.Ordinal); + } + + var container = new CommandProviderCacheContainer + { + Cache = snapshot, + }; + + var path = GetCacheFilePath(); + var json = JsonSerializer.Serialize(container, CommandProviderCacheSerializationContext.Default.CommandProviderCacheContainer!); + await File.WriteAllTextAsync(path, json).ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.LogError("Failed to save command provider cache: ", ex); + } + } + + public void Dispose() + { + _saveGate.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/ICommandProviderCache.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/ICommandProviderCache.cs new file mode 100644 index 0000000000..201e6baa0e --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/ICommandProviderCache.cs @@ -0,0 +1,12 @@ +ο»Ώ// 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 Microsoft.CmdPal.UI.ViewModels.Services; + +public interface ICommandProviderCache +{ + void Memorize(string providerId, CommandProviderCacheItem item); + + CommandProviderCacheItem? Recall(string providerId); +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelCommandManager.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelCommandManager.cs index c113b508d3..4473a1e144 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelCommandManager.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelCommandManager.cs @@ -13,6 +13,7 @@ using Microsoft.CmdPal.Core.Common.Helpers; using Microsoft.CmdPal.Core.Common.Services; using Microsoft.CmdPal.Core.ViewModels; using Microsoft.CmdPal.UI.ViewModels.Messages; +using Microsoft.CmdPal.UI.ViewModels.Services; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; using Microsoft.Extensions.DependencyInjection; @@ -25,6 +26,7 @@ public partial class TopLevelCommandManager : ObservableObject, IDisposable { private readonly IServiceProvider _serviceProvider; + private readonly ICommandProviderCache _commandProviderCache; private readonly TaskScheduler _taskScheduler; private readonly List _builtInCommands = []; @@ -34,9 +36,10 @@ public partial class TopLevelCommandManager : ObservableObject, TaskScheduler IPageContext.Scheduler => _taskScheduler; - public TopLevelCommandManager(IServiceProvider serviceProvider) + public TopLevelCommandManager(IServiceProvider serviceProvider, ICommandProviderCache commandProviderCache) { _serviceProvider = serviceProvider; + _commandProviderCache = commandProviderCache; _taskScheduler = _serviceProvider.GetService()!; WeakReferenceMessenger.Default.Register(this); _reloadCommandsGate = new(ReloadAllCommandsAsyncCore); @@ -319,7 +322,7 @@ public partial class TopLevelCommandManager : ObservableObject, try { await extension.StartExtensionAsync().WaitAsync(TimeSpan.FromSeconds(10)); - return new CommandProviderWrapper(extension, _taskScheduler); + return new CommandProviderWrapper(extension, _taskScheduler, _commandProviderCache); } catch (Exception ex) { diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelViewModel.cs index 6d7f830658..cc863fe362 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelViewModel.cs @@ -199,7 +199,7 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem, IEx _fallbackId = fallback.Id; } - item.PropertyChanged += Item_PropertyChanged; + item.PropertyChangedBackground += Item_PropertyChanged; // UpdateAlias(); // UpdateHotkey(); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs index a44682218f..eb103d3157 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs @@ -32,6 +32,7 @@ using Microsoft.CmdPal.UI.ViewModels.Services; using Microsoft.CommandPalette.Extensions; using Microsoft.Extensions.DependencyInjection; using Microsoft.PowerToys.Telemetry; +using Microsoft.UI.Dispatching; using Microsoft.UI.Xaml; // To learn more about WinUI, the WinUI project structure, @@ -41,7 +42,7 @@ namespace Microsoft.CmdPal.UI; /// /// Provides application-specific behavior to supplement the default Application class. /// -public partial class App : Application +public partial class App : Application, IDisposable { private readonly GlobalErrorHandler _globalErrorHandler = new(); @@ -67,11 +68,13 @@ public partial class App : Application public App() { #if !CMDPAL_DISABLE_GLOBAL_ERROR_HANDLER - _globalErrorHandler.Register(this); + _globalErrorHandler.Register(this, GlobalErrorHandler.Options.Default); #endif Services = ConfigureServices(); + IconCacheProvider.Initialize(Services); + this.InitializeComponent(); // Ensure types used in XAML are preserved for AOT compilation @@ -113,12 +116,13 @@ public partial class App : Application // Root services services.AddSingleton(TaskScheduler.FromCurrentSynchronizationContext()); + var dispatcherQueue = DispatcherQueue.GetForCurrentThread(); AddBuiltInCommands(services); AddCoreServices(services); - AddUIServices(services); + AddUIServices(services, dispatcherQueue); return services.BuildServiceProvider(); } @@ -169,7 +173,7 @@ public partial class App : Application services.AddSingleton(); } - private static void AddUIServices(ServiceCollection services) + private static void AddUIServices(ServiceCollection services, DispatcherQueue dispatcherQueue) { // Models var sm = SettingsModel.LoadSettings(); @@ -178,6 +182,7 @@ public partial class App : Application services.AddSingleton(state); // Services + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -187,6 +192,8 @@ public partial class App : Application services.AddSingleton(); services.AddSingleton(); + + services.AddIconServices(dispatcherQueue); } private static void AddCoreServices(ServiceCollection services) @@ -203,4 +210,11 @@ public partial class App : Application services.AddSingleton(); services.AddSingleton(); } + + public void Dispose() + { + _globalErrorHandler.Dispose(); + EtwTrace.Dispose(); + GC.SuppressFinalize(this); + } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Assets/Icons/ExtensionIconPlaceholder.png b/src/modules/cmdpal/Microsoft.CmdPal.UI/Assets/Icons/ExtensionIconPlaceholder.png new file mode 100644 index 0000000000..cf1cd5c9b6 Binary files /dev/null and b/src/modules/cmdpal/Microsoft.CmdPal.UI/Assets/Icons/ExtensionIconPlaceholder.png differ diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandBar.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandBar.xaml.cs index 5666381d82..b15e8d3c6e 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandBar.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandBar.xaml.cs @@ -44,13 +44,14 @@ public sealed partial class CommandBar : UserControl, public void Receive(OpenContextMenuMessage message) { - if (!ViewModel.ShouldShowContextMenu) - { - return; - } - if (message.Element is null) { + // This is invoked from the "More" button on the command bar + if (!ViewModel.ShouldShowContextMenu) + { + return; + } + _ = DispatcherQueue.TryEnqueue( () => { @@ -65,6 +66,7 @@ public sealed partial class CommandBar : UserControl, } else { + // This is invoked from a specific element _ = DispatcherQueue.TryEnqueue( () => { @@ -126,7 +128,7 @@ public sealed partial class CommandBar : UserControl, private void SettingsIcon_Clicked(object sender, RoutedEventArgs e) { - WeakReferenceMessenger.Default.Send(); + WeakReferenceMessenger.Default.Send(new OpenSettingsMessage()); } private void MoreCommandsButton_Clicked(object sender, RoutedEventArgs e) diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ContextMenu.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ContextMenu.xaml index 36167717ea..4d72e91b6a 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ContextMenu.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ContextMenu.xaml @@ -42,7 +42,7 @@ Margin="4,0,0,0" HorizontalAlignment="Left" SourceKey="{x:Bind Icon, Mode=OneWay}" - SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested}" /> + SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested20}" /> + SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested20}" /> + + + + + @@ -296,7 +296,7 @@ ui:VisualExtensions.NormalizedCenterPoint="0.5,0.5" Foreground="{ThemeResource TextFillColorSecondaryBrush}" SourceKey="{x:Bind ViewModel.CurrentPage.Icon, Mode=OneWay}" - SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested}" + SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested20}" Visibility="{x:Bind ViewModel.CurrentPage.IsNested, Mode=OneWay}"> { - OpenSettings(); + OpenSettings(message.SettingsPageTag); }); } - public void OpenSettings() + public void OpenSettings(string pageTag) { if (_settingsWindow is null) { @@ -270,6 +270,7 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page, _settingsWindow.Activate(); _settingsWindow.BringToFront(); + _settingsWindow.Navigate(pageTag); } public void Receive(ShowDetailsMessage message) diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/ExtensionPage.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/ExtensionPage.xaml index b5b219fe43..df60c83362 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/ExtensionPage.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/ExtensionPage.xaml @@ -60,7 +60,7 @@ Height="20" AutomationProperties.AccessibilityView="Raw" SourceKey="{x:Bind ViewModel.Icon, Mode=OneWay}" - SourceRequested="{x:Bind helpers:IconCacheProvider.SourceRequested}" /> + SourceRequested="{x:Bind helpers:IconCacheProvider.SourceRequested20}" /> @@ -94,7 +94,7 @@ Height="20" AutomationProperties.AccessibilityView="Raw" SourceKey="{x:Bind Icon, Mode=OneWay}" - SourceRequested="{x:Bind helpers:IconCacheProvider.SourceRequested}" /> + SourceRequested="{x:Bind helpers:IconCacheProvider.SourceRequested20}" /> @@ -165,7 +165,7 @@ Height="20" AutomationProperties.AccessibilityView="Raw" SourceKey="{x:Bind Icon, Mode=OneWay}" - SourceRequested="{x:Bind helpers:IconCacheProvider.SourceRequested}" /> + SourceRequested="{x:Bind helpers:IconCacheProvider.SourceRequested20}" /> diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/ExtensionsPage.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/ExtensionsPage.xaml index af529a1bab..e01f26b571 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/ExtensionsPage.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/ExtensionsPage.xaml @@ -229,12 +229,26 @@ - + TargetType="x:Boolean" + Value="{x:Bind Icon.IsSet, FallbackValue=x:False, Mode=OneWay}"> + + + + + + + diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/InternalPage.SampleData.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/InternalPage.SampleData.cs new file mode 100644 index 0000000000..1eb9d41c3b --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/InternalPage.SampleData.cs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.UI.Settings; + +public partial class InternalPage +{ + internal static class SampleData + { + internal static string ExceptionMessageWithPii { get; } = + $""" + Test exception with personal information; thrown from the UI thread + + Here is e-mail address + IPv4 address: 192.168.100.1 + IPv4 loopback address: 127.0.0.1 + MAC address: 00-14-22-01-23-45 + IPv6 address: 2001:0db8:85a3:0000:0000:8a2e:0370:7334 + IPv6 loopback address: ::1 + Password: P@ssw0rd123! + Password=secret + Api key: 1234567890abcdef + PostgreSQL connection string: Host=localhost;Username=postgres;Password=secret;Database=mydb + InstrumentationKey=00000000-0000-0000-0000-000000000000;EndpointSuffix=ai.contoso.com; + X-API-key: 1234567890abcdef + Pet-Shop-Subscription-Key: 1234567890abcdef + Here is a user name {Environment.UserName} + And here is a profile path {Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)}\Pictures + Here is a local app data path {Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData)}\Microsoft\PowerToys\CmdPal + Here is machine name {Environment.MachineName} + JWT token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30 + User email john.doe@company.com failed validation + File not found: {Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments)}\\secret.txt + Connection string: Server=localhost;User ID=admin;Password=secret123;Database=test + Phone number 555-123-4567 is invalid + API key abc123def456ghi789jkl012mno345pqr678 expired + Failed to connect to https://api.internal-company.com/users/12345?token=secret_abc123 + Error accessing file://C:/Users/john.doe/Documents/confidential.pdf + JDBC connection failed: jdbc://database-server:5432/userdb?user=admin&password=secret + FTP upload error: ftp://internal-server.company.com/uploads/user_data.csv + Email service error: mailto:admin@internal-company.com?subject=Alert + """; + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/InternalPage.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/InternalPage.xaml new file mode 100644 index 0000000000..4cf878ec44 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/InternalPage.xaml @@ -0,0 +1,75 @@ +ο»Ώ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/modules/powerdisplay/PowerDisplay/PowerDisplayXAML/MainWindow.xaml.cs b/src/modules/powerdisplay/PowerDisplay/PowerDisplayXAML/MainWindow.xaml.cs new file mode 100644 index 0000000000..62fa666d6e --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/PowerDisplayXAML/MainWindow.xaml.cs @@ -0,0 +1,715 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Threading.Tasks; +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.UI.Windowing; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Controls.Primitives; +using Microsoft.UI.Xaml.Input; +using PowerDisplay.Common.Models; +using PowerDisplay.Configuration; +using PowerDisplay.Helpers; +using PowerDisplay.ViewModels; +using Windows.Graphics; +using WinUIEx; +using Monitor = PowerDisplay.Common.Models.Monitor; + +namespace PowerDisplay +{ + /// + /// PowerDisplay main window + /// + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicMethods)] + public sealed partial class MainWindow : WindowEx, IDisposable + { + private readonly SettingsUtils _settingsUtils = SettingsUtils.Default; + private MainViewModel? _viewModel; + private HotkeyService? _hotkeyService; + + // Expose ViewModel as property for x:Bind + public MainViewModel ViewModel => _viewModel ?? throw new InvalidOperationException("ViewModel not initialized"); + + public MainWindow() + { + Logger.LogInfo("MainWindow constructor: Starting"); + try + { + // 1. Create ViewModel BEFORE InitializeComponent to avoid x:Bind failures + // x:Bind evaluates during InitializeComponent, so ViewModel must exist first + Logger.LogTrace("MainWindow constructor: Creating MainViewModel"); + _viewModel = new MainViewModel(); + Logger.LogTrace("MainWindow constructor: MainViewModel created"); + + Logger.LogTrace("MainWindow constructor: Calling InitializeComponent"); + this.InitializeComponent(); + Logger.LogTrace("MainWindow constructor: InitializeComponent completed"); + + // 2. Configure window immediately (synchronous, no data dependency) + Logger.LogTrace("MainWindow constructor: Configuring window"); + ConfigureWindow(); + + // 3. Set up data context and update bindings + RootGrid.DataContext = _viewModel; + Bindings.Update(); + Logger.LogTrace("MainWindow constructor: Data context set and bindings updated"); + + // 4. Register event handlers + RegisterEventHandlers(); + Logger.LogTrace("MainWindow constructor: Event handlers registered"); + + // 5. Initialize HotkeyService for in-process hotkey handling (CmdPal pattern) + // This avoids IPC timing issues with Runner's centralized hotkey mechanism + Logger.LogTrace("MainWindow constructor: Initializing HotkeyService"); + _hotkeyService = new HotkeyService(_settingsUtils, ToggleWindow); + _hotkeyService.Initialize(this); + Logger.LogTrace("MainWindow constructor: HotkeyService initialized"); + + // Note: ViewModel handles all async initialization internally. + // We listen to InitializationCompleted event to know when data is ready. + // No duplicate initialization here - single responsibility in ViewModel. + Logger.LogInfo("MainWindow constructor: Completed"); + } + catch (Exception ex) + { + Logger.LogError($"MainWindow constructor: Initialization failed: {ex.Message}\n{ex.StackTrace}"); + ShowError($"Unable to start main window: {ex.Message}"); + } + } + + /// + /// Register all event handlers for window and ViewModel + /// + private void RegisterEventHandlers() + { + // Window events + this.Closed += OnWindowClosed; + this.Activated += OnWindowActivated; + + // ViewModel events - _viewModel is guaranteed non-null here as this is called after initialization + if (_viewModel != null) + { + _viewModel.InitializationCompleted += OnViewModelInitializationCompleted; + _viewModel.UIRefreshRequested += OnUIRefreshRequested; + _viewModel.Monitors.CollectionChanged += OnMonitorsCollectionChanged; + _viewModel.PropertyChanged += OnViewModelPropertyChanged; + } + } + + /// + /// Called when ViewModel completes initial monitor discovery. + /// This is the single source of truth for initialization state. + /// + private void OnViewModelInitializationCompleted(object? sender, EventArgs e) + { + _hasInitialized = true; + Logger.LogInfo("MainWindow: Initialization completed via ViewModel event, _hasInitialized=true"); + AdjustWindowSizeToContent(); + } + + private bool _hasInitialized; + + private void ShowError(string message) + { + Logger.LogError($"Error: {message}"); + } + + private void OnWindowActivated(object sender, WindowActivatedEventArgs args) + { + Logger.LogTrace($"OnWindowActivated: WindowActivationState={args.WindowActivationState}"); + + // Auto-hide window when it loses focus (deactivated) + if (args.WindowActivationState == WindowActivationState.Deactivated) + { + Logger.LogInfo("OnWindowActivated: Window deactivated, hiding window"); + HideWindow(); + } + } + + private void OnWindowClosed(object sender, WindowEventArgs args) + { + // If only user operation (although we hide close button), just hide window + args.Handled = true; // Prevent window closing + HideWindow(); + } + + public void ShowWindow() + { + Logger.LogInfo($"ShowWindow: Called, _hasInitialized={_hasInitialized}"); + try + { + // If not initialized, log warning but continue showing + if (!_hasInitialized) + { + Logger.LogWarning("ShowWindow: Window not fully initialized yet, showing anyway"); + } + + // Adjust size BEFORE showing to prevent flicker + // This measures content and positions window at correct size + Logger.LogTrace("ShowWindow: Adjusting window size to content"); + AdjustWindowSizeToContent(); + + // CRITICAL: WinUI3 windows must be Activated at least once to display properly. + // In PowerToys mode, window is created but never activated until first show. + // Without Activate(), Show() may not actually render the window on screen. + Logger.LogTrace("ShowWindow: Calling this.Activate()"); + this.Activate(); + + // Now show the window - it should appear at the correct size + Logger.LogTrace("ShowWindow: Calling this.Show()"); + this.Show(); + + // Ensure window stays on top of other windows + this.IsAlwaysOnTop = true; + Logger.LogTrace("ShowWindow: IsAlwaysOnTop set to true"); + + // Ensure window gets keyboard focus using WinUIEx's BringToFront + // This is necessary for Tab navigation to work without clicking first + this.BringToFront(); + Logger.LogTrace("ShowWindow: BringToFront called"); + + // Clear focus from any interactive element (e.g., Slider) to prevent + // showing the value tooltip when the window opens + RootGrid.Focus(FocusState.Programmatic); + + // Verify window is visible + bool isVisible = IsWindowVisible(); + Logger.LogInfo($"ShowWindow: Window visibility after show: {isVisible}"); + if (!isVisible) + { + Logger.LogError("ShowWindow: Window not visible after show attempt, forcing visibility"); + this.Activate(); + this.Show(); + this.BringToFront(); + Logger.LogInfo($"ShowWindow: After forced show, visibility: {IsWindowVisible()}"); + } + else + { + Logger.LogInfo("ShowWindow: Window shown successfully"); + } + } + catch (Exception ex) + { + Logger.LogError($"ShowWindow: Failed to show window: {ex.Message}\n{ex.StackTrace}"); + throw; + } + } + + public void HideWindow() + { + Logger.LogInfo("HideWindow: Hiding window"); + + // Hide window + this.Hide(); + + Logger.LogTrace($"HideWindow: Window hidden, visibility now: {IsWindowVisible()}"); + } + + /// + /// Check if window is currently visible + /// + /// True if window is visible, false otherwise + public bool IsWindowVisible() + { + bool visible = this.Visible; + Logger.LogTrace($"IsWindowVisible: Returning {visible}"); + return visible; + } + + /// + /// Toggle window visibility (show if hidden, hide if visible) + /// + public void ToggleWindow() + { + bool currentlyVisible = IsWindowVisible(); + Logger.LogInfo($"ToggleWindow: Called, current visibility={currentlyVisible}"); + try + { + if (currentlyVisible) + { + Logger.LogInfo("ToggleWindow: Window is visible, hiding"); + HideWindow(); + } + else + { + Logger.LogInfo("ToggleWindow: Window is hidden, showing"); + ShowWindow(); + } + + Logger.LogInfo($"ToggleWindow: Completed, new visibility={IsWindowVisible()}"); + } + catch (Exception ex) + { + Logger.LogError($"ToggleWindow: Failed to toggle window: {ex.Message}\n{ex.StackTrace}"); + throw; + } + } + + private void OnUIRefreshRequested(object? sender, EventArgs e) + { + // Adjust window size when UI configuration changes (feature visibility toggles) + DispatcherQueue.TryEnqueue(() => AdjustWindowSizeToContent()); + } + + private void OnMonitorsCollectionChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e) + { + // Adjust window size when monitors collection changes (event-driven!) + // The UI binding will update first, then we adjust size + DispatcherQueue.TryEnqueue(Microsoft.UI.Dispatching.DispatcherQueuePriority.Low, () => + { + AdjustWindowSizeToContent(); + }); + } + + private void OnViewModelPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) + { + // Adjust window size when relevant properties change (event-driven!) + if (e.PropertyName == nameof(_viewModel.IsScanning) || + e.PropertyName == nameof(_viewModel.HasMonitors) || + e.PropertyName == nameof(_viewModel.ShowNoMonitorsMessage)) + { + // Use Low priority to ensure UI bindings update first + DispatcherQueue.TryEnqueue(Microsoft.UI.Dispatching.DispatcherQueuePriority.Low, () => + { + AdjustWindowSizeToContent(); + }); + } + } + + private void OnRefreshClick(object sender, RoutedEventArgs e) + { + try + { + // Refresh monitor list + if (_viewModel?.RefreshCommand?.CanExecute(null) == true) + { + _viewModel.RefreshCommand.Execute(null); + + // Window size will be adjusted automatically by OnMonitorsCollectionChanged event! + // No delay needed - event-driven design + } + } + catch (Exception ex) + { + Logger.LogError($"OnRefreshClick failed: {ex}"); + } + } + + private void OnSettingsClick(object sender, RoutedEventArgs e) + { + // Open PowerDisplay settings in PowerToys Settings UI + // mainExecutableIsOnTheParentFolder = true because PowerDisplay is a WinUI 3 app + // deployed in a subfolder (PowerDisplay\) while PowerToys.exe is in the parent folder + SettingsDeepLink.OpenSettings(true); + } + + /// + /// Configure window properties (synchronous, no data dependency) + /// + private void ConfigureWindow() + { + try + { + // Window properties (IsResizable, IsMaximizable, IsMinimizable, + // IsTitleBarVisible, IsShownInSwitchers) are set in XAML + + // Set minimal initial window size - will be adjusted before showing + // Using minimal height to prevent "large window shrinking" flicker + this.AppWindow.Resize(new SizeInt32 { Width = AppConstants.UI.WindowWidth, Height = 100 }); + + // Position window at bottom right corner + PositionWindowAtBottomRight(); + + // Set window title + this.AppWindow.Title = "PowerDisplay"; + + // Custom title bar - completely remove all buttons + var titleBar = this.AppWindow.TitleBar; + if (titleBar != null) + { + // Extend content into title bar area + titleBar.ExtendsContentIntoTitleBar = true; + + // Completely remove title bar height + titleBar.PreferredHeightOption = TitleBarHeightOption.Collapsed; + + // Set all button colors to transparent + titleBar.ButtonBackgroundColor = Windows.UI.Color.FromArgb(0, 0, 0, 0); + titleBar.ButtonInactiveBackgroundColor = Windows.UI.Color.FromArgb(0, 0, 0, 0); + titleBar.ButtonForegroundColor = Windows.UI.Color.FromArgb(0, 0, 0, 0); + titleBar.ButtonHoverBackgroundColor = Windows.UI.Color.FromArgb(0, 0, 0, 0); + titleBar.ButtonHoverForegroundColor = Windows.UI.Color.FromArgb(0, 0, 0, 0); + titleBar.ButtonPressedBackgroundColor = Windows.UI.Color.FromArgb(0, 0, 0, 0); + titleBar.ButtonPressedForegroundColor = Windows.UI.Color.FromArgb(0, 0, 0, 0); + titleBar.ButtonInactiveForegroundColor = Windows.UI.Color.FromArgb(0, 0, 0, 0); + + // Disable title bar interaction area + titleBar.SetDragRectangles(Array.Empty()); + } + + // Use Win32 API to further disable window moving (removes WS_CAPTION, WS_SYSMENU, etc.) + var hWnd = this.GetWindowHandle(); + WindowHelper.DisableWindowMovingAndResizing(hWnd); + } + catch (Exception ex) + { + // Ignore window setup errors + Logger.LogWarning($"Window configuration error: {ex.Message}"); + } + } + + private void AdjustWindowSizeToContent() + { + try + { + if (RootGrid == null) + { + return; + } + + // Force layout update and measure content height + RootGrid.UpdateLayout(); + MainContainer?.Measure(new Windows.Foundation.Size(AppConstants.UI.WindowWidth, double.PositiveInfinity)); + var contentHeight = (int)Math.Ceiling(MainContainer?.DesiredSize.Height ?? 0); + + // Apply min/max height limits and reposition (WindowEx handles DPI automatically) + // Min height ensures window is visible even if content hasn't loaded yet + var finalHeight = Math.Max(AppConstants.UI.MinWindowHeight, Math.Min(contentHeight, AppConstants.UI.MaxWindowHeight)); + Logger.LogTrace($"AdjustWindowSizeToContent: contentHeight={contentHeight}, finalHeight={finalHeight}"); + WindowHelper.PositionWindowBottomRight(this, AppConstants.UI.WindowWidth, finalHeight, AppConstants.UI.WindowRightMargin); + } + catch (Exception ex) + { + Logger.LogError($"Error adjusting window size: {ex.Message}"); + } + } + + private void PositionWindowAtBottomRight() + { + try + { + var windowSize = this.AppWindow.Size; + WindowHelper.PositionWindowBottomRight( + this, // MainWindow inherits from WindowEx + AppConstants.UI.WindowWidth, + windowSize.Height, + AppConstants.UI.WindowRightMargin); + } + catch (Exception) + { + // Window positioning failures are non-critical, silently ignore + } + } + + /// + /// Slider PointerCaptureLost event handler - updates ViewModel when drag completes + /// This is the WinUI3 recommended way to detect drag completion + /// + private void Slider_PointerCaptureLost(object sender, Microsoft.UI.Xaml.Input.PointerRoutedEventArgs e) + { + var slider = sender as Slider; + if (slider == null) + { + return; + } + + var propertyName = slider.Tag as string; + var monitorVm = slider.DataContext as MonitorViewModel; + + if (monitorVm == null || propertyName == null) + { + return; + } + + // Get final value after drag completes + int finalValue = (int)slider.Value; + + // Now update the ViewModel, which will trigger hardware operation + switch (propertyName) + { + case "Brightness": + monitorVm.Brightness = finalValue; + break; + case "Contrast": + monitorVm.ContrastPercent = finalValue; + break; + case "Volume": + monitorVm.Volume = finalValue; + break; + } + } + + /// + /// Slider KeyUp event handler - updates ViewModel when arrow keys are released + /// This handles keyboard navigation for accessibility + /// + private void Slider_KeyUp(object sender, Microsoft.UI.Xaml.Input.KeyRoutedEventArgs e) + { + // Only handle arrow keys (Left, Right, Up, Down) + if (e.Key != Windows.System.VirtualKey.Left && + e.Key != Windows.System.VirtualKey.Right && + e.Key != Windows.System.VirtualKey.Up && + e.Key != Windows.System.VirtualKey.Down) + { + return; + } + + var slider = sender as Slider; + if (slider == null) + { + return; + } + + var propertyName = slider.Tag as string; + var monitorVm = slider.DataContext as MonitorViewModel; + + if (monitorVm == null || propertyName == null) + { + return; + } + + // Get the current value after key press + int finalValue = (int)slider.Value; + + // Update the ViewModel, which will trigger hardware operation + switch (propertyName) + { + case "Brightness": + monitorVm.Brightness = finalValue; + break; + case "Contrast": + monitorVm.ContrastPercent = finalValue; + break; + case "Volume": + monitorVm.Volume = finalValue; + break; + } + } + + /// + /// Input source ListView selection changed handler - switches the monitor input source + /// + private async void InputSourceListView_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (sender is not ListView listView) + { + return; + } + + // Get the selected input source item + var selectedItem = listView.SelectedItem as InputSourceItem; + if (selectedItem == null) + { + return; + } + + Logger.LogInfo($"[UI] InputSourceListView_SelectionChanged: Selected {selectedItem.Name} (0x{selectedItem.Value:X2}) for monitor {selectedItem.MonitorId}"); + + // Find the monitor by ID + MonitorViewModel? monitorVm = null; + if (!string.IsNullOrEmpty(selectedItem.MonitorId) && _viewModel != null) + { + monitorVm = _viewModel.Monitors.FirstOrDefault(m => m.Id == selectedItem.MonitorId); + } + + if (monitorVm == null) + { + Logger.LogWarning("[UI] InputSourceListView_SelectionChanged: Could not find MonitorViewModel"); + return; + } + + // Set the input source + await monitorVm.SetInputSourceAsync(selectedItem.Value); + } + + /// + /// Power state ListView selection changed handler - switches the monitor power state. + /// Note: Selecting any state other than "On" will turn off the display. + /// + private async void PowerStateListView_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (sender is not ListView listView) + { + return; + } + + // Get the selected power state item + var selectedItem = listView.SelectedItem as PowerStateItem; + if (selectedItem == null) + { + return; + } + + // Skip if "On" is selected - the monitor is already on + if (selectedItem.Value == PowerStateItem.PowerStateOn) + { + return; + } + + Logger.LogInfo($"[UI] PowerStateListView_SelectionChanged: Selected {selectedItem.Name} (0x{selectedItem.Value:X2}) for monitor {selectedItem.MonitorId}"); + + // Find the monitor by ID + MonitorViewModel? monitorVm = null; + if (!string.IsNullOrEmpty(selectedItem.MonitorId) && _viewModel != null) + { + monitorVm = _viewModel.Monitors.FirstOrDefault(m => m.Id == selectedItem.MonitorId); + } + + if (monitorVm == null) + { + Logger.LogWarning("[UI] PowerStateListView_SelectionChanged: Could not find MonitorViewModel"); + return; + } + + // Set the power state - this will turn off the display + await monitorVm.SetPowerStateAsync(selectedItem.Value); + } + + /// + /// Rotation button click handler - changes monitor orientation + /// + private async void RotationButton_Click(object sender, RoutedEventArgs e) + { + if (sender is not Microsoft.UI.Xaml.Controls.Primitives.ToggleButton toggleButton) + { + return; + } + + // Get the orientation from the Tag + if (toggleButton.Tag is not string tagStr || !int.TryParse(tagStr, out int orientation)) + { + Logger.LogWarning("[UI] RotationButton_Click: Invalid Tag"); + return; + } + + var monitorVm = toggleButton.DataContext as MonitorViewModel; + if (monitorVm == null) + { + Logger.LogWarning("[UI] RotationButton_Click: Could not find MonitorViewModel"); + return; + } + + // If clicking the current orientation, restore the checked state and do nothing + if (monitorVm.CurrentRotation == orientation) + { + toggleButton.IsChecked = true; + return; + } + + Logger.LogInfo($"[UI] RotationButton_Click: Setting rotation for {monitorVm.Name} to {orientation}"); + + // Set the rotation + await monitorVm.SetRotationAsync(orientation); + } + + /// + /// Profile selection changed handler - applies the selected profile + /// + private void ProfileListView_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (sender is not ListView listView) + { + return; + } + + var selectedProfile = listView.SelectedItem as PowerDisplayProfile; + if (selectedProfile == null || !selectedProfile.IsValid()) + { + return; + } + + Logger.LogInfo($"[UI] ProfileListView_SelectionChanged: Applying profile '{selectedProfile.Name}'"); + + // Apply profile via ViewModel command + if (_viewModel?.ApplyProfileCommand?.CanExecute(selectedProfile) == true) + { + _viewModel.ApplyProfileCommand.Execute(selectedProfile); + } + + // Close the flyout after selection + ProfilesFlyout?.Hide(); + + // Clear selection to allow reselecting the same profile + listView.SelectedItem = null; + } + + /// + /// Color temperature selection changed handler - applies the selected color temperature preset + /// + private async void ColorTemperatureListView_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (sender is not ListView listView) + { + return; + } + + var selectedItem = listView.SelectedItem as ColorTemperatureItem; + if (selectedItem == null) + { + return; + } + + Logger.LogInfo($"[UI] ColorTemperatureListView_SelectionChanged: Selected {selectedItem.DisplayName} (0x{selectedItem.VcpValue:X2}) for monitor {selectedItem.MonitorId}"); + + // Find the monitor by ID + MonitorViewModel? monitorVm = null; + if (!string.IsNullOrEmpty(selectedItem.MonitorId) && _viewModel != null) + { + monitorVm = _viewModel.Monitors.FirstOrDefault(m => m.Id == selectedItem.MonitorId); + } + + if (monitorVm == null) + { + Logger.LogWarning("[UI] ColorTemperatureListView_SelectionChanged: Could not find MonitorViewModel"); + return; + } + + // Apply the color temperature + await monitorVm.SetColorTemperatureAsync(selectedItem.VcpValue); + + // Clear selection to allow reselecting the same preset + listView.SelectedItem = null; + } + + /// + /// Flyout opened event handler - sets focus to the first focusable element inside the flyout. + /// This enables keyboard navigation when the flyout opens. + /// + private void Flyout_Opened(object sender, object e) + { + if (sender is Flyout flyout && flyout.Content is FrameworkElement content) + { + // Use DispatcherQueue to ensure the flyout content is fully rendered before setting focus + DispatcherQueue.TryEnqueue(() => + { + var firstFocusable = FocusManager.FindFirstFocusableElement(content); + if (firstFocusable is Control control) + { + control.Focus(FocusState.Programmatic); + } + }); + } + } + + public void Dispose() + { + _hotkeyService?.Dispose(); + _viewModel?.Dispose(); + GC.SuppressFinalize(this); + } + + /// + /// Reload hotkey settings. Call this when settings change. + /// + public void ReloadHotkeySettings() + { + _hotkeyService?.ReloadSettings(); + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay/PowerDisplayXAML/MonitorIcon.xaml b/src/modules/powerdisplay/PowerDisplay/PowerDisplayXAML/MonitorIcon.xaml new file mode 100644 index 0000000000..10244cee6c --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/PowerDisplayXAML/MonitorIcon.xaml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/modules/powerdisplay/PowerDisplay/PowerDisplayXAML/MonitorIcon.xaml.cs b/src/modules/powerdisplay/PowerDisplay/PowerDisplayXAML/MonitorIcon.xaml.cs new file mode 100644 index 0000000000..3948332d0b --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/PowerDisplayXAML/MonitorIcon.xaml.cs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +namespace PowerDisplay; + +public sealed partial class MonitorIcon : UserControl +{ + public MonitorIcon() + { + InitializeComponent(); + } + + public bool IsBuiltIn + { + get => (bool)GetValue(IsBuiltInProperty); + set => SetValue(IsBuiltInProperty, value); + } + + public static readonly DependencyProperty IsBuiltInProperty = DependencyProperty.Register(nameof(IsBuiltIn), typeof(bool), typeof(MonitorIcon), new PropertyMetadata(false, OnPropertyChanged)); + + public int MonitorNumber + { + get => (int)GetValue(MonitorNumberProperty); + set => SetValue(MonitorNumberProperty, value); + } + + public static readonly DependencyProperty MonitorNumberProperty = DependencyProperty.Register(nameof(MonitorNumber), typeof(int), typeof(MonitorIcon), new PropertyMetadata(0, OnPropertyChanged)); + + private static void OnPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var monIcon = (MonitorIcon)d; + if (monIcon.IsBuiltIn) + { + VisualStateManager.GoToState(monIcon, "BuiltIn", true); + } + else + { + VisualStateManager.GoToState(monIcon, "Monitor", true); + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay/Program.cs b/src/modules/powerdisplay/PowerDisplay/Program.cs new file mode 100644 index 0000000000..2553637b6f --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/Program.cs @@ -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; +using System.Threading; +using System.Threading.Tasks; +using ManagedCommon; +using Microsoft.UI.Dispatching; +using Microsoft.Windows.AppLifecycle; + +namespace PowerDisplay +{ + public static partial class Program + { + private static App? _app; + + // LibraryImport for AOT compatibility - COM wait constants + private const uint CowaitDefault = 0; + private const uint InfiniteTimeout = 0xFFFFFFFF; + + [LibraryImport("ole32.dll")] + private static partial int CoWaitForMultipleObjects( + uint dwFlags, + uint dwTimeout, + int cHandles, + nint[] pHandles, + out uint lpdwIndex); + + [STAThread] + public static int Main(string[] args) + { + // Initialize COM wrappers first (needed for AppInstance) + WinRT.ComWrappersSupport.InitializeComWrappers(); + + // Single instance check BEFORE logger initialization to avoid creating extra log files + // Command Palette pattern: check for existing instance first + var activationArgs = AppInstance.GetCurrent().GetActivatedEventArgs(); + var keyInstance = AppInstance.FindOrRegisterForKey("PowerToys_PowerDisplay_Instance"); + + if (!keyInstance.IsCurrent) + { + // Another instance exists - redirect and exit WITHOUT initializing logger + // This prevents creation of extra log files for short-lived redirect processes + RedirectActivationTo(activationArgs, keyInstance); + return 0; + } + + // This is the primary instance - now initialize logger + Logger.InitializeLogger("\\PowerDisplay\\Logs"); + Logger.LogInfo("PowerDisplay starting"); + + // Register activation handler for future redirects + keyInstance.Activated += OnActivated; + + // Parse command line arguments: + // args[0] = runner_pid (Awake pattern) + // args[1] = pipe_name (Named Pipe for IPC with module DLL) + int runnerPid = -1; + string? pipeName = null; + + if (args.Length >= 1) + { + if (int.TryParse(args[0], out int parsedPid)) + { + runnerPid = parsedPid; + } + } + + if (args.Length >= 2) + { + pipeName = args[1]; + } + + Microsoft.UI.Xaml.Application.Start((p) => + { + var context = new DispatcherQueueSynchronizationContext(DispatcherQueue.GetForCurrentThread()); + SynchronizationContext.SetSynchronizationContext(context); + _app = new App(runnerPid, pipeName); + }); + return 0; + } + + /// + /// Redirect activation to existing instance (Command Palette pattern) + /// Called BEFORE logger is initialized, so no logging here + /// + private static void RedirectActivationTo(AppActivationArguments args, AppInstance keyInstance) + { + // Do the redirection on another thread, and use a non-blocking + // wait method to wait for the redirection to complete. + using var redirectSemaphore = new Semaphore(0, 1); + var redirectTimeout = TimeSpan.FromSeconds(10); + + _ = Task.Run(() => + { + using var cts = new CancellationTokenSource(redirectTimeout); + try + { + keyInstance.RedirectActivationToAsync(args) + .AsTask(cts.Token) + .GetAwaiter() + .GetResult(); + } + catch + { + // Silently ignore errors - logger not initialized yet + } + finally + { + redirectSemaphore.Release(); + } + }); + + // Use CoWaitForMultipleObjects to pump COM messages while waiting + nint[] handles = [redirectSemaphore.SafeWaitHandle.DangerousGetHandle()]; + _ = CoWaitForMultipleObjects( + CowaitDefault, + InfiniteTimeout, + 1, + handles, + out _); + } + + /// + /// Called when an existing instance is activated by another process. + /// This happens when Quick Access or other launchers start the process while one is already running. + /// We toggle the window to show it - this allows Quick Access launch to work properly. + /// + private static void OnActivated(object? sender, AppActivationArguments args) + { + Logger.LogInfo("OnActivated: Redirect activation received - toggling window"); + + // Toggle the main window on redirect activation + if (_app?.MainWindow is MainWindow mainWindow) + { + // Dispatch to UI thread since OnActivated may be called from a different thread + mainWindow.DispatcherQueue.TryEnqueue(() => + { + Logger.LogTrace("OnActivated: Toggling window from redirect activation"); + mainWindow.ToggleWindow(); + }); + } + else + { + Logger.LogWarning("OnActivated: MainWindow not available for toggle"); + } + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay/Serialization/IPCMessageAction.cs b/src/modules/powerdisplay/PowerDisplay/Serialization/IPCMessageAction.cs new file mode 100644 index 0000000000..b929db1b52 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/Serialization/IPCMessageAction.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Text.Json.Serialization; + +namespace PowerDisplay.Serialization +{ + /// + /// IPC message wrapper for parsing action-based messages. + /// Used in App.xaml.cs for dynamic IPC command handling. + /// + internal sealed class IpcMessageAction + { + [JsonPropertyName("action")] + public string? Action { get; set; } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay/Serialization/JsonSourceGenerationContext.cs b/src/modules/powerdisplay/PowerDisplay/Serialization/JsonSourceGenerationContext.cs new file mode 100644 index 0000000000..239d777693 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/Serialization/JsonSourceGenerationContext.cs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.PowerToys.Settings.UI.Library; +using PowerDisplay.Common.Models; + +namespace PowerDisplay.Serialization +{ + /// + /// JSON source generation context for AOT compatibility. + /// Eliminates reflection-based JSON serialization. + /// Note: MonitorStateFile and MonitorStateEntry are now in PowerDisplay.Lib + /// and should be serialized using ProfileSerializationContext from the Lib. + /// + [JsonSerializable(typeof(IpcMessageAction))] + [JsonSerializable(typeof(PowerDisplaySettings))] + [JsonSerializable(typeof(PowerDisplayProfiles))] + [JsonSerializable(typeof(PowerDisplayProfile))] + [JsonSerializable(typeof(ProfileMonitorSetting))] + + // MonitorInfo and related types (Settings.UI.Library) + [JsonSerializable(typeof(MonitorInfo))] + [JsonSerializable(typeof(VcpCodeDisplayInfo))] + [JsonSerializable(typeof(VcpValueInfo))] + + // Generic collection types + [JsonSerializable(typeof(List))] + [JsonSerializable(typeof(List))] + [JsonSerializable(typeof(List))] + [JsonSerializable(typeof(List))] + [JsonSerializable(typeof(List))] + [JsonSerializable(typeof(List))] + + [JsonSourceGenerationOptions( + WriteIndented = true, + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.Never)] + internal sealed partial class AppJsonContext : JsonSerializerContext + { + } +} diff --git a/src/modules/powerdisplay/PowerDisplay/Services/LightSwitchService.cs b/src/modules/powerdisplay/PowerDisplay/Services/LightSwitchService.cs new file mode 100644 index 0000000000..7182fd32ed --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/Services/LightSwitchService.cs @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library; +using Settings.UI.Library; + +namespace PowerDisplay.Services +{ + /// + /// Service for handling LightSwitch theme change events. + /// Reads LightSwitch settings using the standard PowerToys settings pattern. + /// + public static class LightSwitchService + { + private const string LogPrefix = "[LightSwitch]"; + + /// + /// Get the profile name to apply for the given theme. + /// + /// Whether the theme changed to light mode. + /// The profile name to apply, or null if no profile is configured. + public static string? GetProfileForTheme(bool isLightMode) + { + try + { + Logger.LogInfo($"{LogPrefix} Processing theme change to {(isLightMode ? "light" : "dark")} mode"); + + var settings = SettingsUtils.Default.GetSettingsOrDefault(LightSwitchSettings.ModuleName); + + if (settings?.Properties == null) + { + Logger.LogWarning($"{LogPrefix} LightSwitch settings not found"); + return null; + } + + string? profileName; + if (isLightMode) + { + if (!settings.Properties.EnableLightModeProfile.Value) + { + Logger.LogInfo($"{LogPrefix} Light mode profile is disabled"); + return null; + } + + profileName = settings.Properties.LightModeProfile.Value; + } + else + { + if (!settings.Properties.EnableDarkModeProfile.Value) + { + Logger.LogInfo($"{LogPrefix} Dark mode profile is disabled"); + return null; + } + + profileName = settings.Properties.DarkModeProfile.Value; + } + + if (string.IsNullOrEmpty(profileName) || profileName == "(None)") + { + Logger.LogInfo($"{LogPrefix} No profile configured for {(isLightMode ? "light" : "dark")} mode"); + return null; + } + + Logger.LogInfo($"{LogPrefix} Profile to apply: {profileName}"); + return profileName; + } + catch (Exception ex) + { + Logger.LogError($"{LogPrefix} Failed to get profile for theme: {ex.Message}"); + return null; + } + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay/Strings/en-us/Resources.resw b/src/modules/powerdisplay/PowerDisplay/Strings/en-us/Resources.resw new file mode 100644 index 0000000000..166140cb1f --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/Strings/en-us/Resources.resw @@ -0,0 +1,96 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Scanning monitors.. + + + No monitors detected + + + Rescan connected monitors + + + Settings + + + Monitor + + + Brightness + + + Contrast + + + Volume + + + Rotation + + + Normal (0Β°) + + + Rotate left (270Β°) + + + Rotate right (90Β°) + + + Inverted (180Β°) + + + Volume + + + Contrast + + + Brightness + + + PowerDisplay + + + Settings + + + Exit + + + Quick apply profiles + + + Identify monitors + + + Input source + + + Power state + + + More options + + + Profiles + + + Color temperature + + + Color temperature + + diff --git a/src/modules/powerdisplay/PowerDisplay/Telemetry/Events/PowerDisplaySettingsTelemetryEvent.cs b/src/modules/powerdisplay/PowerDisplay/Telemetry/Events/PowerDisplaySettingsTelemetryEvent.cs new file mode 100644 index 0000000000..d29976742f --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/Telemetry/Events/PowerDisplaySettingsTelemetryEvent.cs @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Diagnostics.Tracing; +using Microsoft.PowerToys.Telemetry; +using Microsoft.PowerToys.Telemetry.Events; + +namespace PowerDisplay.Telemetry.Events +{ + /// + /// Telemetry event for PowerDisplay settings + /// Sent when Runner requests settings telemetry via send_settings_telemetry() + /// + [EventData] + public class PowerDisplaySettingsTelemetryEvent : EventBase, IEvent + { + public new string EventName => "PowerDisplay_Settings"; + + public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage; + + /// + /// Whether the hotkey is enabled + /// + public bool HotkeyEnabled { get; set; } + + /// + /// Whether the tray icon is enabled + /// + public bool TrayIconEnabled { get; set; } + + /// + /// Number of monitors currently detected + /// + public int MonitorCount { get; set; } + + /// + /// Number of profiles saved + /// + public int ProfileCount { get; set; } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay/Telemetry/Events/PowerDisplayStartEvent.cs b/src/modules/powerdisplay/PowerDisplay/Telemetry/Events/PowerDisplayStartEvent.cs new file mode 100644 index 0000000000..397fc722e2 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/Telemetry/Events/PowerDisplayStartEvent.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Diagnostics.Tracing; +using Microsoft.PowerToys.Telemetry; +using Microsoft.PowerToys.Telemetry.Events; + +namespace PowerDisplay.Telemetry.Events +{ + [EventData] + public class PowerDisplayStartEvent : EventBase, IEvent + { + public new string EventName => "PowerDisplay_Start"; + + public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage; + } +} diff --git a/src/modules/powerdisplay/PowerDisplay/ViewModels/ColorTemperatureItem.cs b/src/modules/powerdisplay/PowerDisplay/ViewModels/ColorTemperatureItem.cs new file mode 100644 index 0000000000..281dd2d3a4 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/ViewModels/ColorTemperatureItem.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace PowerDisplay.ViewModels; + +/// +/// Represents a color temperature preset option for display in UI +/// +public class ColorTemperatureItem +{ + /// + /// VCP value for this color temperature preset (e.g., 0x05 for 6500K) + /// + public int VcpValue { get; set; } + + /// + /// Human-readable name (e.g., "6500K", "sRGB", "User 1") + /// + public string DisplayName { get; set; } = string.Empty; + + /// + /// Whether this preset is currently selected + /// + public bool IsSelected { get; set; } + + /// + /// Monitor ID for direct lookup (Flyout popup is not in visual tree) + /// + public string MonitorId { get; set; } = string.Empty; +} diff --git a/src/modules/powerdisplay/PowerDisplay/ViewModels/InputSourceItem.cs b/src/modules/powerdisplay/PowerDisplay/ViewModels/InputSourceItem.cs new file mode 100644 index 0000000000..25a53efbe0 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/ViewModels/InputSourceItem.cs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.UI.Xaml; + +namespace PowerDisplay.ViewModels; + +/// +/// Represents an input source option for display in UI +/// +public class InputSourceItem +{ + /// + /// VCP value for this input source (e.g., 0x11 for HDMI-1) + /// + public int Value { get; set; } + + /// + /// Human-readable name (e.g., "HDMI-1", "DisplayPort-1") + /// + public string Name { get; set; } = string.Empty; + + /// + /// Visibility of selection indicator (Visible when selected) + /// + public Visibility SelectionVisibility { get; set; } = Visibility.Collapsed; + + /// + /// Monitor ID for direct lookup (Flyout popup is not in visual tree) + /// + public string MonitorId { get; set; } = string.Empty; +} diff --git a/src/modules/powerdisplay/PowerDisplay/ViewModels/MainViewModel.Monitors.cs b/src/modules/powerdisplay/PowerDisplay/ViewModels/MainViewModel.Monitors.cs new file mode 100644 index 0000000000..02b3a35009 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/ViewModels/MainViewModel.Monitors.cs @@ -0,0 +1,165 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library; +using PowerDisplay.Common.Models; +using PowerDisplay.Helpers; +using Monitor = PowerDisplay.Common.Models.Monitor; + +namespace PowerDisplay.ViewModels; + +/// +/// MainViewModel - Monitor discovery and management methods +/// +public partial class MainViewModel +{ + private async Task InitializeAsync(CancellationToken cancellationToken = default) + { + try + { + IsScanning = true; + + // Discover monitors + var monitors = await _monitorManager.DiscoverMonitorsAsync(cancellationToken); + + // Update UI on the dispatcher thread, then complete initialization asynchronously + _dispatcherQueue.TryEnqueue(() => + { + try + { + UpdateMonitorList(monitors, isInitialLoad: true); + + // Complete initialization asynchronously (restore settings if enabled) + // IsScanning remains true until restore completes + _ = CompleteInitializationAsync(); + } + catch (Exception lambdaEx) + { + Logger.LogError($"[InitializeAsync] UI update failed: {lambdaEx.Message}"); + IsScanning = false; + } + }); + } + catch (Exception ex) + { + Logger.LogError($"[InitializeAsync] Monitor discovery failed: {ex.Message}"); + _dispatcherQueue.TryEnqueue(() => + { + IsScanning = false; + }); + } + } + + /// + /// Complete initialization by restoring settings (if enabled) and firing completion event. + /// IsScanning remains true until this method completes, so user sees discovery UI during restore. + /// + private async Task CompleteInitializationAsync() + { + try + { + // Check if we should restore settings on startup + var settings = _settingsUtils.GetSettingsOrDefault(PowerDisplaySettings.ModuleName); + if (settings.Properties.RestoreSettingsOnStartup) + { + await RestoreMonitorSettingsAsync(); + } + } + catch (Exception ex) + { + Logger.LogError($"[CompleteInitializationAsync] Failed to restore settings: {ex.Message}"); + } + finally + { + // Always complete initialization, even if restore failed + IsScanning = false; + IsInitialized = true; + + // Start watching for display changes after initialization + StartDisplayWatching(); + + // Notify listeners that initialization is complete + InitializationCompleted?.Invoke(this, EventArgs.Empty); + } + } + + /// + /// Refresh monitors list asynchronously. + /// + /// If true, skip the IsScanning check (used by OnDisplayChanged which sets IsScanning before calling). + public async Task RefreshMonitorsAsync(bool skipScanningCheck = false) + { + if (!skipScanningCheck && IsScanning) + { + return; + } + + try + { + IsScanning = true; + + var monitors = await _monitorManager.DiscoverMonitorsAsync(_cancellationTokenSource.Token); + + _dispatcherQueue.TryEnqueue(() => + { + UpdateMonitorList(monitors, isInitialLoad: false); + IsScanning = false; + }); + } + catch (Exception ex) + { + Logger.LogError($"[RefreshMonitorsAsync] Refresh failed: {ex.Message}"); + _dispatcherQueue.TryEnqueue(() => + { + IsScanning = false; + }); + } + } + + private void UpdateMonitorList(IReadOnlyList monitors, bool isInitialLoad) + { + Monitors.Clear(); + + // Load settings to check for hidden monitors + var settings = _settingsUtils.GetSettingsOrDefault(PowerDisplaySettings.ModuleName); + var hiddenMonitorIds = GetHiddenMonitorIds(settings); + + foreach (var monitor in monitors) + { + // Skip monitors that are marked as hidden in settings + if (hiddenMonitorIds.Contains(monitor.Id)) + { + continue; + } + + var vm = new MonitorViewModel(monitor, _monitorManager, this); + ApplyFeatureVisibility(vm, settings); + Monitors.Add(vm); + } + + OnPropertyChanged(nameof(HasMonitors)); + OnPropertyChanged(nameof(ShowNoMonitorsMessage)); + + // Save monitor information to settings + SaveMonitorsToSettings(); + + // Note: RestoreMonitorSettingsAsync is now called from InitializeAsync/CompleteInitializationAsync + // to ensure scanning state is maintained until restore completes + } + + /// + /// Get set of hidden monitor IDs from settings + /// + private HashSet GetHiddenMonitorIds(PowerDisplaySettings settings) + => new HashSet( + settings.Properties.Monitors + .Where(m => m.IsHidden) + .Select(m => m.Id)); +} diff --git a/src/modules/powerdisplay/PowerDisplay/ViewModels/MainViewModel.Settings.cs b/src/modules/powerdisplay/PowerDisplay/ViewModels/MainViewModel.Settings.cs new file mode 100644 index 0000000000..d01af35c6c --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/ViewModels/MainViewModel.Settings.cs @@ -0,0 +1,559 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.PowerToys.Telemetry; +using PowerDisplay.Common.Models; +using PowerDisplay.Common.Services; +using PowerDisplay.Common.Utils; +using PowerDisplay.Serialization; +using PowerDisplay.Services; +using PowerDisplay.Telemetry.Events; +using PowerToys.Interop; + +namespace PowerDisplay.ViewModels; + +/// +/// MainViewModel - Settings UI synchronization and Profile management methods +/// +public partial class MainViewModel +{ + /// + /// Check if a value is within the valid range (inclusive). + /// + private static bool IsValueInRange(int value, int min, int max) => value >= min && value <= max; + + /// + /// Apply settings changes from Settings UI (IPC event handler entry point) + /// Only applies UI configuration changes. Hardware parameter changes (e.g., color temperature) + /// should be triggered via custom actions to avoid unwanted side effects when non-hardware + /// settings (like RestoreSettingsOnStartup) are changed. + /// + public void ApplySettingsFromUI() + { + try + { + // Rebuild monitor list with updated hidden monitor settings + // UpdateMonitorList already handles filtering hidden monitors + UpdateMonitorList(_monitorManager.Monitors, isInitialLoad: false); + + // Reload UI display settings first (includes custom VCP mappings) + // Must be loaded before ApplyUIConfiguration so names are available for UI refresh + LoadUIDisplaySettings(); + + // Apply UI configuration changes only (feature visibility toggles, etc.) + // Hardware parameters (brightness, color temperature) are applied via custom actions + var settings = _settingsUtils.GetSettingsOrDefault("PowerDisplay"); + ApplyUIConfiguration(settings); + + // Reload profiles in case they were added/updated/deleted in Settings UI + LoadProfiles(); + + // Notify MonitorViewModels to refresh their custom VCP name displays + foreach (var monitor in Monitors) + { + monitor.RefreshCustomVcpNames(); + } + } + catch (Exception ex) + { + Logger.LogError($"[Settings] Failed to apply settings from UI: {ex.Message}"); + } + } + + /// + /// Apply UI-only configuration changes (feature visibility toggles) + /// Synchronous, lightweight operation + /// + private void ApplyUIConfiguration(PowerDisplaySettings settings) + { + try + { + foreach (var monitorVm in Monitors) + { + ApplyFeatureVisibility(monitorVm, settings); + } + + // Trigger UI refresh + UIRefreshRequested?.Invoke(this, EventArgs.Empty); + } + catch (Exception ex) + { + Logger.LogError($"[Settings] Failed to apply UI configuration: {ex.Message}"); + } + } + + /// + /// Apply profile by name (called via Named Pipe from Settings UI) + /// This is the new direct method that receives the profile name via IPC. + /// + /// The name of the profile to apply. + public async Task ApplyProfileByNameAsync(string profileName) + { + try + { + Logger.LogInfo($"[Profile] Applying profile by name: {profileName}"); + + // Load profiles and find the requested one + var profilesData = ProfileService.LoadProfiles(); + var profile = profilesData.GetProfile(profileName); + + if (profile == null || !profile.IsValid()) + { + Logger.LogWarning($"[Profile] Profile '{profileName}' not found or invalid"); + return; + } + + // Apply the profile settings to monitors + await ApplyProfileAsync(profile.MonitorSettings); + Logger.LogInfo($"[Profile] Successfully applied profile: {profileName}"); + } + catch (Exception ex) + { + Logger.LogError($"[Profile] Failed to apply profile '{profileName}': {ex.Message}"); + } + } + + /// + /// Handle theme change from LightSwitch by applying the appropriate profile. + /// Called from App.xaml.cs when LightSwitch theme events are received. + /// + /// Whether the theme changed to light mode. + public void ApplyLightSwitchProfile(bool isLightMode) + { + var profileName = LightSwitchService.GetProfileForTheme(isLightMode); + + if (string.IsNullOrEmpty(profileName)) + { + return; + } + + _ = Task.Run(async () => + { + try + { + Logger.LogInfo($"[LightSwitch Integration] Applying profile: {profileName}"); + + // Load and apply the profile + var profilesData = ProfileService.LoadProfiles(); + var profile = profilesData.GetProfile(profileName); + + if (profile == null || !profile.IsValid()) + { + Logger.LogWarning($"[LightSwitch Integration] Profile '{profileName}' not found or invalid"); + return; + } + + // Apply the profile - need to dispatch to UI thread since MonitorViewModels are UI-bound + var tcs = new TaskCompletionSource(); + var enqueued = _dispatcherQueue.TryEnqueue(() => + { + // Start the async operation and handle completion + _ = ApplyProfileAndCompleteAsync(profile.MonitorSettings, tcs); + }); + + if (!enqueued) + { + Logger.LogError($"[LightSwitch Integration] Failed to enqueue profile application to UI thread"); + return; + } + + await tcs.Task; + } + catch (Exception ex) + { + Logger.LogError($"[LightSwitch Integration] Failed to apply profile: {ex.GetType().Name}: {ex.Message}"); + if (ex.InnerException != null) + { + Logger.LogError($"[LightSwitch Integration] Inner exception: {ex.InnerException.GetType().Name}: {ex.InnerException.Message}"); + } + } + }); + } + + /// + /// Helper method to apply profile and signal completion. + /// + private async Task ApplyProfileAndCompleteAsync(List monitorSettings, TaskCompletionSource tcs) + { + try + { + await ApplyProfileAsync(monitorSettings); + tcs.SetResult(true); + } + catch (Exception ex) + { + tcs.SetException(ex); + } + } + + /// + /// Apply profile settings to monitors + /// + private async Task ApplyProfileAsync(List monitorSettings) + { + var updateTasks = new List(); + + foreach (var setting in monitorSettings) + { + // Find monitor by Id (unique identifier) + var monitorVm = Monitors.FirstOrDefault(m => m.Id == setting.MonitorId); + + if (monitorVm == null) + { + continue; + } + + // Apply brightness if included in profile + if (setting.Brightness.HasValue && + IsValueInRange(setting.Brightness.Value, monitorVm.MinBrightness, monitorVm.MaxBrightness)) + { + updateTasks.Add(monitorVm.SetBrightnessAsync(setting.Brightness.Value)); + } + + // Apply contrast if supported and value provided + if (setting.Contrast.HasValue && monitorVm.ShowContrast && + IsValueInRange(setting.Contrast.Value, monitorVm.MinContrast, monitorVm.MaxContrast)) + { + updateTasks.Add(monitorVm.SetContrastAsync(setting.Contrast.Value)); + } + + // Apply volume if supported and value provided + if (setting.Volume.HasValue && monitorVm.ShowVolume && + IsValueInRange(setting.Volume.Value, monitorVm.MinVolume, monitorVm.MaxVolume)) + { + updateTasks.Add(monitorVm.SetVolumeAsync(setting.Volume.Value)); + } + + // Apply color temperature if included in profile + if (setting.ColorTemperatureVcp.HasValue && setting.ColorTemperatureVcp.Value > 0) + { + updateTasks.Add(monitorVm.SetColorTemperatureAsync(setting.ColorTemperatureVcp.Value)); + } + } + + // Wait for all updates to complete + if (updateTasks.Count > 0) + { + await Task.WhenAll(updateTasks); + } + } + + /// + /// Restore monitor settings from state file - ONLY called at startup when RestoreSettingsOnStartup is enabled. + /// Compares saved values with current hardware values and only writes when different. + /// + public async Task RestoreMonitorSettingsAsync() + { + try + { + IsLoading = true; + var updateTasks = new List(); + + foreach (var monitorVm in Monitors) + { + var savedState = _stateManager.GetMonitorParameters(monitorVm.Id); + if (!savedState.HasValue) + { + continue; + } + + // Restore brightness if different from current + if (IsValueInRange(savedState.Value.Brightness, monitorVm.MinBrightness, monitorVm.MaxBrightness) && + savedState.Value.Brightness != monitorVm.Brightness) + { + updateTasks.Add(monitorVm.SetBrightnessAsync(savedState.Value.Brightness)); + } + + // Restore color temperature if different from current + if (savedState.Value.ColorTemperatureVcp > 0 && + savedState.Value.ColorTemperatureVcp != monitorVm.ColorTemperature) + { + updateTasks.Add(monitorVm.SetColorTemperatureAsync(savedState.Value.ColorTemperatureVcp)); + } + + // Restore contrast if different from current + if (monitorVm.ShowContrast && + IsValueInRange(savedState.Value.Contrast, monitorVm.MinContrast, monitorVm.MaxContrast) && + savedState.Value.Contrast != monitorVm.Contrast) + { + updateTasks.Add(monitorVm.SetContrastAsync(savedState.Value.Contrast)); + } + + // Restore volume if different from current + if (monitorVm.ShowVolume && + IsValueInRange(savedState.Value.Volume, monitorVm.MinVolume, monitorVm.MaxVolume) && + savedState.Value.Volume != monitorVm.Volume) + { + updateTasks.Add(monitorVm.SetVolumeAsync(savedState.Value.Volume)); + } + } + + if (updateTasks.Count > 0) + { + await Task.WhenAll(updateTasks); + } + } + catch (Exception ex) + { + Logger.LogError($"[RestoreMonitorSettings] Failed: {ex.Message}"); + } + finally + { + IsLoading = false; + } + } + + /// + /// Apply feature visibility settings to a monitor ViewModel. + /// Only shows features that are both enabled by user AND supported by hardware. + /// + private void ApplyFeatureVisibility(MonitorViewModel monitorVm, PowerDisplaySettings settings) + { + var monitorSettings = settings.Properties.Monitors.FirstOrDefault(m => + m.Id == monitorVm.Id); + + if (monitorSettings != null) + { + // Only show features that are both enabled by user AND supported by hardware + monitorVm.ShowContrast = monitorSettings.EnableContrast && monitorVm.SupportsContrast; + monitorVm.ShowVolume = monitorSettings.EnableVolume && monitorVm.SupportsVolume; + monitorVm.ShowInputSource = monitorSettings.EnableInputSource && monitorVm.SupportsInputSource; + monitorVm.ShowRotation = monitorSettings.EnableRotation; + monitorVm.ShowColorTemperature = monitorSettings.EnableColorTemperature && monitorVm.SupportsColorTemperature; + monitorVm.ShowPowerState = monitorSettings.EnablePowerState && monitorVm.SupportsPowerState; + } + } + + /// + /// Thread-safe save method that can be called from background threads. + /// Does not access UI collections or update UI properties. + /// + public void SaveMonitorSettingDirect(string monitorId, string property, int value) + { + try + { + // This is thread-safe - _stateManager has internal locking + // No UI thread operations, no ObservableCollection access + _stateManager.UpdateMonitorParameter(monitorId, property, value); + } + catch (Exception ex) + { + // Only log, don't update UI from background thread + Logger.LogError($"Failed to queue setting save for monitorId '{monitorId}': {ex.Message}"); + } + } + + /// + /// Save monitor information to settings.json for Settings UI to read + /// + private void SaveMonitorsToSettings() + { + try + { + // Load current settings to preserve user preferences (including IsHidden) + var settings = _settingsUtils.GetSettingsOrDefault(PowerDisplaySettings.ModuleName); + + // Create lookup of existing monitors by Id to preserve settings + // Filter out monitors with empty IDs to avoid dictionary key collision errors + var existingMonitorSettings = settings.Properties.Monitors + .Where(m => !string.IsNullOrEmpty(m.Id)) + .GroupBy(m => m.Id) + .ToDictionary(g => g.Key, g => g.First()); + + // Build monitor list using Settings UI's MonitorInfo model + // Only include monitors with valid (non-empty) IDs to auto-fix corrupted settings + var monitors = new List(); + + foreach (var vm in Monitors) + { + // Skip monitors with empty IDs - they are invalid and would cause issues + if (string.IsNullOrEmpty(vm.Id)) + { + Logger.LogWarning($"[SaveMonitors] Skipping monitor '{vm.Name}' with empty Id"); + continue; + } + + var monitorInfo = CreateMonitorInfo(vm); + ApplyPreservedUserSettings(monitorInfo, existingMonitorSettings); + monitors.Add(monitorInfo); + } + + // Also add hidden monitors from existing settings (monitors that are hidden but still connected) + // Only include those with valid IDs + foreach (var existingMonitor in settings.Properties.Monitors.Where(m => m.IsHidden && !string.IsNullOrEmpty(m.Id))) + { + // Only add if not already in the list (to avoid duplicates) + if (!monitors.Any(m => m.Id == existingMonitor.Id)) + { + monitors.Add(existingMonitor); + } + } + + // Update monitors list + settings.Properties.Monitors = monitors; + + // Save back to settings.json using source-generated context for AOT + _settingsUtils.SaveSettings( + System.Text.Json.JsonSerializer.Serialize(settings, AppJsonContext.Default.PowerDisplaySettings), + PowerDisplaySettings.ModuleName); + + // Signal Settings UI that monitor list has been updated + SignalMonitorsRefreshEvent(); + } + catch (Exception ex) + { + Logger.LogError($"Failed to save monitors to settings.json: {ex.Message}"); + } + } + + /// + /// Create MonitorInfo object from MonitorViewModel + /// + private Microsoft.PowerToys.Settings.UI.Library.MonitorInfo CreateMonitorInfo(MonitorViewModel vm) + { + // Validate monitor Id - this should never be empty for properly discovered monitors + if (string.IsNullOrEmpty(vm.Id)) + { + Logger.LogWarning($"[CreateMonitorInfo] Monitor '{vm.Name}' has empty Id - this may cause issues with Settings UI"); + } + + var monitorInfo = new Microsoft.PowerToys.Settings.UI.Library.MonitorInfo + { + Name = vm.Name, + Id = vm.Id, + CommunicationMethod = vm.CommunicationMethod, + CurrentBrightness = vm.Brightness, + ColorTemperatureVcp = vm.ColorTemperature, + CapabilitiesRaw = vm.CapabilitiesRaw, + VcpCodesFormatted = vm.VcpCapabilitiesInfo?.GetSortedVcpCodes() + .Select(info => FormatVcpCodeForDisplay(info.Code, info)) + .ToList() ?? new List(), + + // Infer support flags from VCP capabilities + // VCP 0x12 (18) = Contrast, 0x14 (20) = Color Temperature, 0x60 (96) = Input Source, 0x62 (98) = Volume, 0xD6 (214) = Power Mode + SupportsContrast = vm.VcpCapabilitiesInfo?.SupportedVcpCodes.ContainsKey(0x12) ?? false, + SupportsColorTemperature = vm.VcpCapabilitiesInfo?.SupportedVcpCodes.ContainsKey(0x14) ?? false, + SupportsInputSource = vm.VcpCapabilitiesInfo?.SupportedVcpCodes.ContainsKey(0x60) ?? false, + SupportsVolume = vm.VcpCapabilitiesInfo?.SupportedVcpCodes.ContainsKey(0x62) ?? false, + SupportsPowerState = vm.VcpCapabilitiesInfo?.SupportedVcpCodes.ContainsKey(0xD6) ?? false, + + // Default Enable* to match Supports* for new monitors (first-time setup) + // ApplyPreservedUserSettings will override these with saved user preferences if they exist + EnableContrast = vm.VcpCapabilitiesInfo?.SupportedVcpCodes.ContainsKey(0x12) ?? false, + EnableVolume = vm.VcpCapabilitiesInfo?.SupportedVcpCodes.ContainsKey(0x62) ?? false, + EnableInputSource = vm.VcpCapabilitiesInfo?.SupportedVcpCodes.ContainsKey(0x60) ?? false, + EnableColorTemperature = vm.VcpCapabilitiesInfo?.SupportedVcpCodes.ContainsKey(0x14) ?? false, + EnablePowerState = vm.VcpCapabilitiesInfo?.SupportedVcpCodes.ContainsKey(0xD6) ?? false, + + // Monitor number for display name formatting + MonitorNumber = vm.MonitorNumber, + }; + + return monitorInfo; + } + + /// + /// Apply preserved user settings from existing monitor settings + /// + private void ApplyPreservedUserSettings( + Microsoft.PowerToys.Settings.UI.Library.MonitorInfo monitorInfo, + Dictionary existingSettings) + { + if (existingSettings.TryGetValue(monitorInfo.Id, out var existingMonitor)) + { + monitorInfo.IsHidden = existingMonitor.IsHidden; + monitorInfo.EnableContrast = existingMonitor.EnableContrast; + monitorInfo.EnableVolume = existingMonitor.EnableVolume; + monitorInfo.EnableInputSource = existingMonitor.EnableInputSource; + monitorInfo.EnableRotation = existingMonitor.EnableRotation; + monitorInfo.EnableColorTemperature = existingMonitor.EnableColorTemperature; + monitorInfo.EnablePowerState = existingMonitor.EnablePowerState; + } + } + + /// + /// Signal Settings UI that the monitor list has been refreshed + /// + private void SignalMonitorsRefreshEvent() + { + EventHelper.SignalEvent(Constants.RefreshPowerDisplayMonitorsEvent()); + } + + /// + /// Format VCP code information for display in Settings UI + /// + private Microsoft.PowerToys.Settings.UI.Library.VcpCodeDisplayInfo FormatVcpCodeForDisplay(byte code, VcpCodeInfo info) + { + var result = new Microsoft.PowerToys.Settings.UI.Library.VcpCodeDisplayInfo + { + Code = info.FormattedCode, + Title = info.FormattedTitle, + }; + + if (info.IsContinuous) + { + result.Values = "Continuous range"; + result.HasValues = true; + } + else if (info.HasDiscreteValues) + { + var formattedValues = info.SupportedValues + .Select(v => Common.Utils.VcpNames.GetFormattedValueName(code, v)) + .ToList(); + result.Values = $"Values: {string.Join(", ", formattedValues)}"; + result.HasValues = true; + + // Populate value list for Settings UI ComboBox + // Store raw name (without formatting) so Settings UI can format it consistently + result.ValueList = info.SupportedValues + .Select(v => new Microsoft.PowerToys.Settings.UI.Library.VcpValueInfo + { + Value = $"0x{v:X2}", + Name = Common.Utils.VcpNames.GetValueName(code, v), + }) + .ToList(); + } + else + { + result.HasValues = false; + } + + return result; + } + + /// + /// Send settings telemetry event (triggered by Runner via send_settings_telemetry()) + /// + public void SendSettingsTelemetry() + { + try + { + // Load current settings to get hotkey and tray icon status + var settings = _settingsUtils.GetSettingsOrDefault(PowerDisplaySettings.ModuleName); + + // Load profiles to get count + var profilesData = ProfileService.LoadProfiles(); + + var telemetryEvent = new PowerDisplaySettingsTelemetryEvent + { + HotkeyEnabled = settings.Properties.ActivationShortcut?.IsValid() ?? false, + TrayIconEnabled = settings.Properties.ShowSystemTrayIcon, + MonitorCount = Monitors.Count, + ProfileCount = profilesData?.Profiles?.Count ?? 0, + }; + + PowerToysTelemetry.Log.WriteEvent(telemetryEvent); + } + catch (Exception ex) + { + Logger.LogError($"[Telemetry] Failed to send settings telemetry: {ex.Message}"); + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay/ViewModels/MainViewModel.cs b/src/modules/powerdisplay/PowerDisplay/ViewModels/MainViewModel.cs new file mode 100644 index 0000000000..e16b34cadb --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/ViewModels/MainViewModel.cs @@ -0,0 +1,449 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.Input; +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.UI; +using Microsoft.UI.Dispatching; +using Microsoft.UI.Windowing; +using PowerDisplay.Common.Drivers; +using PowerDisplay.Common.Drivers.DDC; +using PowerDisplay.Common.Models; +using PowerDisplay.Common.Services; +using PowerDisplay.Helpers; +using PowerDisplay.PowerDisplayXAML; + +namespace PowerDisplay.ViewModels; + +/// +/// Main ViewModel for the PowerDisplay application. +/// Split into partial classes for better maintainability: +/// - MainViewModel.cs: Core properties, construction, and disposal +/// - MainViewModel.Monitors.cs: Monitor discovery and management +/// - MainViewModel.Settings.cs: Settings UI synchronization and profiles +/// +[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicMethods)] +public partial class MainViewModel : INotifyPropertyChanged, IDisposable +{ + [LibraryImport("user32.dll", EntryPoint = "GetMonitorInfoW", StringMarshalling = StringMarshalling.Utf16)] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool GetMonitorInfo(IntPtr hMonitor, ref MonitorInfoEx lpmi); + + private readonly MonitorManager _monitorManager; + private readonly DispatcherQueue _dispatcherQueue; + private readonly CancellationTokenSource _cancellationTokenSource; + private readonly SettingsUtils _settingsUtils; + private readonly MonitorStateManager _stateManager; + private readonly DisplayChangeWatcher _displayChangeWatcher; + + private ObservableCollection _monitors; + private ObservableCollection _profiles; + private bool _isScanning; + private bool _isInitialized; + private bool _isLoading; + + /// + /// Event triggered when UI refresh is requested due to settings changes + /// + public event EventHandler? UIRefreshRequested; + + /// + /// Event triggered when initial monitor discovery is completed. + /// Used by MainWindow to know when data is ready for display. + /// + public event EventHandler? InitializationCompleted; + + public MainViewModel() + { + _dispatcherQueue = DispatcherQueue.GetForCurrentThread(); + _cancellationTokenSource = new CancellationTokenSource(); + _monitors = new ObservableCollection(); + _profiles = new ObservableCollection(); + _isScanning = true; + + // Initialize settings utils + _settingsUtils = SettingsUtils.Default; + _stateManager = new MonitorStateManager(); + + // Initialize the monitor manager + _monitorManager = new MonitorManager(); + + // Load profiles for quick apply feature + LoadProfiles(); + + // Load UI display settings (profile switcher, identify button, color temp switcher) + LoadUIDisplaySettings(); + + // Initialize display change watcher for auto-refresh on monitor plug/unplug + // Use MonitorRefreshDelay from settings to allow hardware to stabilize after plug/unplug + var settings = _settingsUtils.GetSettingsOrDefault(PowerDisplaySettings.ModuleName); + int delaySeconds = Math.Clamp(settings?.Properties?.MonitorRefreshDelay ?? 5, 1, 30); + _displayChangeWatcher = new DisplayChangeWatcher(_dispatcherQueue, TimeSpan.FromSeconds(delaySeconds)); + _displayChangeWatcher.DisplayChanged += OnDisplayChanged; + + // Start initial discovery + _ = InitializeAsync(_cancellationTokenSource.Token); + } + + public ObservableCollection Monitors + { + get => _monitors; + set + { + _monitors = value; + OnPropertyChanged(); + } + } + + public ObservableCollection Profiles + { + get => _profiles; + set + { + _profiles = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(HasProfiles)); + } + } + + public bool HasProfiles => Profiles.Count > 0; + + // UI display control properties - loaded from settings + private bool _showProfileSwitcher = true; + private bool _showIdentifyMonitorsButton = true; + + /// + /// Gets a value indicating whether to show the profile switcher button. + /// Combines settings value with HasProfiles check. + /// + public bool ShowProfileSwitcherButton => _showProfileSwitcher && HasProfiles; + + /// + /// Gets or sets a value indicating whether to show the profile switcher (from settings). + /// + public bool ShowProfileSwitcher + { + get => _showProfileSwitcher; + set + { + if (_showProfileSwitcher != value) + { + _showProfileSwitcher = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(ShowProfileSwitcherButton)); + } + } + } + + /// + /// Gets or sets a value indicating whether to show the identify monitors button. + /// + public bool ShowIdentifyMonitorsButton + { + get => _showIdentifyMonitorsButton; + set + { + if (_showIdentifyMonitorsButton != value) + { + _showIdentifyMonitorsButton = value; + OnPropertyChanged(); + } + } + } + + // Custom VCP mappings - loaded from settings + private List _customVcpMappings = new(); + + /// + /// Gets or sets the custom VCP value name mappings. + /// These mappings override the default VCP value names for color temperature and input source. + /// + public List CustomVcpMappings + { + get => _customVcpMappings; + set + { + _customVcpMappings = value ?? new List(); + OnPropertyChanged(); + } + } + + public bool IsScanning + { + get => _isScanning; + set + { + if (_isScanning != value) + { + _isScanning = value; + OnPropertyChanged(); + + // Dependent properties that change with IsScanning + OnPropertyChanged(nameof(HasMonitors)); + OnPropertyChanged(nameof(ShowNoMonitorsMessage)); + OnPropertyChanged(nameof(IsInteractionEnabled)); + } + } + } + + public bool HasMonitors => !IsScanning && Monitors.Count > 0; + + public bool ShowNoMonitorsMessage => !IsScanning && Monitors.Count == 0; + + public bool IsInitialized + { + get => _isInitialized; + private set + { + _isInitialized = value; + OnPropertyChanged(); + } + } + + public bool IsLoading + { + get => _isLoading; + private set + { + _isLoading = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(IsInteractionEnabled)); + } + } + + /// + /// Gets a value indicating whether user interaction is enabled (not loading or scanning). + /// + public bool IsInteractionEnabled => !IsLoading && !IsScanning; + + [RelayCommand] + private async Task RefreshAsync() => await RefreshMonitorsAsync(); + + [RelayCommand] + private unsafe void IdentifyMonitors() + { + try + { + // Get all display areas (virtual desktop regions) + var displayAreas = DisplayArea.FindAll(); + + // Get all monitor info from QueryDisplayConfig + var allDisplayInfo = DdcCiNative.GetAllMonitorDisplayInfo().Values.ToList(); + + // Build GDI name to MonitorNumber(s) mapping + // Note: In mirror mode, multiple monitors may share the same GdiDeviceName + var gdiToMonitorNumbers = allDisplayInfo + .Where(info => info.MonitorNumber > 0) + .GroupBy(info => info.GdiDeviceName, StringComparer.OrdinalIgnoreCase) + .ToDictionary( + g => g.Key, + g => g.Select(info => info.MonitorNumber).Distinct().OrderBy(n => n).ToList(), + StringComparer.OrdinalIgnoreCase); + + // For each DisplayArea, get its HMONITOR, then get GDI device name to find MonitorNumber(s) + int windowsCreated = 0; + for (int i = 0; i < displayAreas.Count; i++) + { + var displayArea = displayAreas[i]; + + // Convert DisplayId to HMONITOR + var hMonitor = Win32Interop.GetMonitorFromDisplayId(displayArea.DisplayId); + if (hMonitor == IntPtr.Zero) + { + continue; + } + + // Get GDI device name from HMONITOR + var monitorInfo = new MonitorInfoEx { CbSize = (uint)sizeof(MonitorInfoEx) }; + if (!GetMonitorInfo(hMonitor, ref monitorInfo)) + { + continue; + } + + var gdiDeviceName = monitorInfo.GetDeviceName(); + + // Look up MonitorNumber(s) by GDI device name + if (!gdiToMonitorNumbers.TryGetValue(gdiDeviceName, out var monitorNumbers) || monitorNumbers.Count == 0) + { + continue; + } + + // Format display text: single number for normal mode, "1|2" for mirror mode + var displayText = string.Join("|", monitorNumbers); + + // Create and position identify window + var identifyWindow = new IdentifyWindow(displayText); + identifyWindow.PositionOnDisplay(displayArea); + identifyWindow.Activate(); + windowsCreated++; + } + } + catch (Exception ex) + { + Logger.LogError($"Failed to identify monitors: {ex.Message}"); + } + } + + [RelayCommand] + private async Task ApplyProfile(PowerDisplayProfile? profile) + { + if (profile != null && profile.IsValid()) + { + await ApplyProfileAsync(profile.MonitorSettings); + } + } + + public event PropertyChangedEventHandler? PropertyChanged; + + protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + + public void Dispose() + { + // Cancel all async operations first + _cancellationTokenSource?.Cancel(); + + // Dispose each resource independently to ensure all get cleaned up + try + { + _displayChangeWatcher?.Dispose(); + } + catch + { + } + + // Dispose monitor view models + foreach (var vm in Monitors) + { + try + { + vm.Dispose(); + } + catch + { + } + } + + try + { + _monitorManager?.Dispose(); + } + catch + { + } + + try + { + _stateManager?.Dispose(); + } + catch + { + } + + try + { + _cancellationTokenSource?.Dispose(); + } + catch + { + } + + try + { + Monitors.Clear(); + } + catch + { + } + + GC.SuppressFinalize(this); + } + + /// + /// Load profiles from disk for quick apply feature + /// + private void LoadProfiles() + { + try + { + var profilesData = ProfileService.LoadProfiles(); + _profiles.Clear(); + foreach (var profile in profilesData.Profiles) + { + _profiles.Add(profile); + } + + OnPropertyChanged(nameof(HasProfiles)); + OnPropertyChanged(nameof(ShowProfileSwitcherButton)); + } + catch (Exception ex) + { + Logger.LogError($"[Profile] Failed to load profiles: {ex.Message}"); + } + } + + /// + /// Load UI display settings from settings file + /// + private void LoadUIDisplaySettings() + { + try + { + var settings = _settingsUtils.GetSettingsOrDefault(PowerDisplaySettings.ModuleName); + ShowProfileSwitcher = settings.Properties.ShowProfileSwitcher; + ShowIdentifyMonitorsButton = settings.Properties.ShowIdentifyMonitorsButton; + + // Load custom VCP mappings (now using shared type from PowerDisplay.Common.Models) + CustomVcpMappings = settings.Properties.CustomVcpMappings?.ToList() ?? new List(); + Logger.LogInfo($"[Settings] Loaded {CustomVcpMappings.Count} custom VCP mappings"); + } + catch (Exception ex) + { + Logger.LogError($"[Settings] Failed to load UI display settings: {ex.Message}"); + } + } + + /// + /// Handles display configuration changes detected by the DisplayChangeWatcher. + /// The DisplayChangeWatcher already applies the configured delay (MonitorRefreshDelay) + /// to allow hardware to stabilize, so we can refresh immediately here. + /// + private async void OnDisplayChanged(object? sender, EventArgs e) + { + // Set scanning state to provide visual feedback + IsScanning = true; + + // Perform refresh - DisplayChangeWatcher has already waited for hardware to stabilize + await RefreshMonitorsAsync(skipScanningCheck: true); + } + + /// + /// Starts watching for display changes. Call after initialization is complete. + /// + public void StartDisplayWatching() + { + _displayChangeWatcher.Start(); + } + + /// + /// Stops watching for display changes. + /// + public void StopDisplayWatching() + { + _displayChangeWatcher.Stop(); + } +} diff --git a/src/modules/powerdisplay/PowerDisplay/ViewModels/MonitorViewModel.cs b/src/modules/powerdisplay/PowerDisplay/ViewModels/MonitorViewModel.cs new file mode 100644 index 0000000000..b50fcc03e4 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/ViewModels/MonitorViewModel.cs @@ -0,0 +1,901 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.Input; +using ManagedCommon; +using Microsoft.UI.Xaml; + +using PowerDisplay.Common.Models; +using PowerDisplay.Configuration; +using PowerDisplay.Helpers; +using Monitor = PowerDisplay.Common.Models.Monitor; + +namespace PowerDisplay.ViewModels; + +/// +/// ViewModel for individual monitor +/// +public partial class MonitorViewModel : INotifyPropertyChanged, IDisposable +{ + private readonly Monitor _monitor; + private readonly MonitorManager _monitorManager; + private readonly MainViewModel? _mainViewModel; + + private int _brightness; + private int _contrast; + private int _volume; + private bool _isAvailable; + + // Visibility settings (controlled by Settings UI) + private bool _showContrast; + private bool _showVolume; + private bool _showInputSource; + private bool _showRotation; + private bool _showPowerState; + + /// + /// Updates a property value directly without triggering hardware updates. + /// Used during initialization to update UI from saved state. + /// + internal void UpdatePropertySilently(string propertyName, int value) + { + switch (propertyName) + { + case nameof(Brightness): + _brightness = value; + OnPropertyChanged(nameof(Brightness)); + break; + case nameof(Contrast): + _contrast = value; + OnPropertyChanged(nameof(Contrast)); + OnPropertyChanged(nameof(ContrastPercent)); + break; + case nameof(Volume): + _volume = value; + OnPropertyChanged(nameof(Volume)); + break; + case nameof(ColorTemperature): + // Update underlying monitor model + _monitor.CurrentColorTemperature = value; + OnPropertyChanged(nameof(ColorTemperature)); + OnPropertyChanged(nameof(ColorTemperaturePresetName)); + break; + } + } + + /// + /// Apply brightness with hardware update and state persistence. + /// + /// Brightness value (0-100) + public async Task SetBrightnessAsync(int brightness) + { + brightness = Math.Clamp(brightness, MinBrightness, MaxBrightness); + + // Update UI state immediately + if (_brightness != brightness) + { + _brightness = brightness; + OnPropertyChanged(nameof(Brightness)); + } + + // Apply to hardware + await ApplyPropertyToHardwareAsync(nameof(Brightness), brightness, _monitorManager.SetBrightnessAsync); + } + + /// + /// Apply contrast with hardware update and state persistence. + /// + public async Task SetContrastAsync(int contrast) + { + contrast = Math.Clamp(contrast, MinContrast, MaxContrast); + + if (_contrast != contrast) + { + _contrast = contrast; + OnPropertyChanged(nameof(Contrast)); + OnPropertyChanged(nameof(ContrastPercent)); + } + + await ApplyPropertyToHardwareAsync(nameof(Contrast), contrast, _monitorManager.SetContrastAsync); + } + + /// + /// Apply volume with hardware update and state persistence. + /// + public async Task SetVolumeAsync(int volume) + { + volume = Math.Clamp(volume, MinVolume, MaxVolume); + + if (_volume != volume) + { + _volume = volume; + OnPropertyChanged(nameof(Volume)); + } + + await ApplyPropertyToHardwareAsync(nameof(Volume), volume, _monitorManager.SetVolumeAsync); + } + + /// + /// Unified method to apply color temperature with hardware update and state persistence. + /// Always immediate (no debouncing for discrete preset values). + /// + public async Task SetColorTemperatureAsync(int colorTemperature) + { + try + { + var result = await _monitorManager.SetColorTemperatureAsync(Id, colorTemperature); + + if (result.IsSuccess) + { + _monitor.CurrentColorTemperature = colorTemperature; + OnPropertyChanged(nameof(ColorTemperature)); + OnPropertyChanged(nameof(ColorTemperaturePresetName)); + + // Refresh the color presets list to update IsSelected checkmarks in UI + RefreshAvailableColorPresets(); + + _mainViewModel?.SaveMonitorSettingDirect(_monitor.Id, nameof(ColorTemperature), colorTemperature); + } + else + { + Logger.LogWarning($"[{Id}] Failed to set color temperature: {result.ErrorMessage}"); + } + } + catch (Exception ex) + { + Logger.LogError($"[{Id}] Exception setting color temperature: {ex.Message}"); + } + } + + /// + /// Generic method to apply a monitor property to hardware and persist state. + /// Consolidates common logic for brightness, contrast, and volume operations. + /// + /// Name of the property being set (for logging and state persistence) + /// Value to apply + /// Async function to call on MonitorManager + private async Task ApplyPropertyToHardwareAsync( + string propertyName, + int value, + Func> setAsyncFunc) + { + try + { + var result = await setAsyncFunc(Id, value, default); + + if (result.IsSuccess) + { + _mainViewModel?.SaveMonitorSettingDirect(_monitor.Id, propertyName, value); + } + else + { + Logger.LogWarning($"[{Id}] Failed to set {propertyName.ToLowerInvariant()}: {result.ErrorMessage}"); + } + } + catch (Exception ex) + { + Logger.LogError($"[{Id}] Exception setting {propertyName.ToLowerInvariant()}: {ex.Message}"); + } + } + + // Property to access IsInteractionEnabled from parent ViewModel + public bool IsInteractionEnabled => _mainViewModel?.IsInteractionEnabled ?? true; + + public MonitorViewModel(Monitor monitor, MonitorManager monitorManager, MainViewModel mainViewModel) + { + _monitor = monitor; + _monitorManager = monitorManager; + _mainViewModel = mainViewModel; + + // Subscribe to MainViewModel property changes to update IsInteractionEnabled + if (_mainViewModel != null) + { + _mainViewModel.PropertyChanged += OnMainViewModelPropertyChanged; + } + + // Subscribe to underlying Monitor property changes (e.g., Orientation updates in mirror mode) + _monitor.PropertyChanged += OnMonitorPropertyChanged; + + // Initialize Show properties based on hardware capabilities + _showContrast = monitor.SupportsContrast; + _showVolume = monitor.SupportsVolume; + _showInputSource = monitor.SupportsInputSource; + _showPowerState = monitor.SupportsPowerState; + _showColorTemperature = monitor.SupportsColorTemperature; + + // Initialize basic properties from monitor + _brightness = monitor.CurrentBrightness; + _contrast = monitor.CurrentContrast; + _volume = monitor.CurrentVolume; + _isAvailable = monitor.IsAvailable; + } + + public string Id => _monitor.Id; + + public string Name => _monitor.Name; + + /// + /// Gets the monitor number from the underlying monitor model (Windows DISPLAY number) + /// + public int MonitorNumber => _monitor.MonitorNumber; + + /// + /// Gets the display name - includes monitor number when multiple monitors exist. + /// Follows the same logic as Settings UI's MonitorInfo.DisplayName for consistency. + /// + public string DisplayName + { + get + { + var monitorCount = _mainViewModel?.Monitors?.Count ?? 0; + + // Show monitor number only when there are multiple monitors and MonitorNumber is valid + if (monitorCount > 1 && MonitorNumber > 0) + { + return $"{Name} {MonitorNumber}"; + } + + return Name; + } + } + + public string CommunicationMethod => _monitor.CommunicationMethod; + + public bool IsInternal => _monitor.CommunicationMethod == "WMI"; + + public string? CapabilitiesRaw => _monitor.CapabilitiesRaw; + + public VcpCapabilities? VcpCapabilitiesInfo => _monitor.VcpCapabilitiesInfo; + + /// + /// Gets the icon glyph based on communication method + /// WMI monitors (laptop internal displays) use laptop icon, others use external monitor icon + /// + public string MonitorIconGlyph => _monitor.CommunicationMethod?.Contains("WMI", StringComparison.OrdinalIgnoreCase) == true + ? AppConstants.UI.InternalMonitorGlyph // Laptop icon for WMI + : AppConstants.UI.ExternalMonitorGlyph; // External monitor icon for DDC/CI and others + + // Monitor property ranges + public int MinBrightness => _monitor.MinBrightness; + + public int MaxBrightness => _monitor.MaxBrightness; + + public int MinContrast => _monitor.MinContrast; + + public int MaxContrast => _monitor.MaxContrast; + + public int MinVolume => _monitor.MinVolume; + + public int MaxVolume => _monitor.MaxVolume; + + // Advanced control display logic + public bool HasAdvancedControls => ShowContrast || ShowVolume; + + /// + /// Gets a value indicating whether this monitor supports contrast control via VCP 0x12 + /// + public bool SupportsContrast => _monitor.SupportsContrast; + + /// + /// Gets a value indicating whether this monitor supports volume control via VCP 0x62 + /// + public bool SupportsVolume => _monitor.SupportsVolume; + + public bool ShowContrast + { + get => _showContrast; + set + { + if (_showContrast != value) + { + _showContrast = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(HasAdvancedControls)); + } + } + } + + public bool ShowVolume + { + get => _showVolume; + set + { + if (_showVolume != value) + { + _showVolume = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(HasAdvancedControls)); + } + } + } + + public bool ShowInputSource + { + get => _showInputSource; + set + { + if (_showInputSource != value) + { + _showInputSource = value; + OnPropertyChanged(); + OnMoreButtonPropertiesChanged(); + } + } + } + + /// + /// Gets or sets a value indicating whether to show power state control in the More Button flyout. + /// + public bool ShowPowerState + { + get => _showPowerState && SupportsPowerState; + set + { + if (_showPowerState != value) + { + _showPowerState = value; + OnPropertyChanged(); + OnMoreButtonPropertiesChanged(); + } + } + } + + /// + /// Gets a value indicating whether the More Button should be visible. + /// Visible when at least one feature (InputSource or PowerState) is enabled. + /// + public bool ShowMoreButton => ShowInputSource || ShowPowerState; + + /// + /// Gets a value indicating whether to show separator after Input Source section. + /// Only shown when both InputSource and PowerState are visible. + /// + public bool ShowSeparatorAfterInputSource => ShowInputSource && ShowPowerState; + + /// + /// Notifies property changes for More Button related properties. + /// + private void OnMoreButtonPropertiesChanged() + { + OnPropertyChanged(nameof(ShowMoreButton)); + OnPropertyChanged(nameof(ShowSeparatorAfterInputSource)); + } + + /// + /// Gets or sets a value indicating whether to show rotation controls (controlled by Settings UI, default false). + /// + public bool ShowRotation + { + get => _showRotation; + set + { + if (_showRotation != value) + { + _showRotation = value; + OnPropertyChanged(); + } + } + } + + /// + /// Gets the current rotation/orientation of the monitor (0=normal, 1=90Β°, 2=180Β°, 3=270Β°) + /// + public int CurrentRotation => _monitor.Orientation; + + /// + /// Gets a value indicating whether the current rotation is 0Β° (normal/default). + /// + public bool IsRotation0 => CurrentRotation == 0; + + /// + /// Gets a value indicating whether the current rotation is 90Β° (rotated right). + /// + public bool IsRotation1 => CurrentRotation == 1; + + /// + /// Gets a value indicating whether the current rotation is 180Β° (inverted). + /// + public bool IsRotation2 => CurrentRotation == 2; + + /// + /// Gets a value indicating whether the current rotation is 270Β° (rotated left). + /// + public bool IsRotation3 => CurrentRotation == 3; + + /// + /// Set rotation/orientation for this monitor. + /// Note: MonitorManager.SetRotationAsync will refresh all monitors' orientations after success, + /// which triggers PropertyChanged through OnMonitorPropertyChanged - no manual notification needed here. + /// + /// Orientation: 0=normal, 1=90Β°, 2=180Β°, 3=270Β° + public async Task SetRotationAsync(int orientation) + { + // Validate orientation range (0=normal, 1=90Β°, 2=180Β°, 3=270Β°) + if (orientation < 0 || orientation > 3) + { + return; + } + + // If already at this orientation, do nothing + if (CurrentRotation == orientation) + { + return; + } + + try + { + var result = await _monitorManager.SetRotationAsync(Id, orientation); + + if (!result.IsSuccess) + { + Logger.LogWarning($"[{Id}] Failed to set rotation: {result.ErrorMessage}"); + } + } + catch (Exception ex) + { + Logger.LogError($"[{Id}] Exception setting rotation: {ex.Message}"); + } + } + + public int Brightness + { + get => _brightness; + set + { + if (_brightness != value) + { + _ = SetBrightnessAsync(value); + } + } + } + + /// + /// Gets color temperature VCP preset value (from VCP code 0x14). + /// Read-only in flyout UI - controlled via Settings UI. + /// Returns the raw VCP value (e.g., 0x05 for 6500K). + /// + public int ColorTemperature => _monitor.CurrentColorTemperature; + + /// + /// Gets human-readable color temperature preset name (e.g., "6500K", "sRGB") + /// Uses custom mappings if available; falls back to built-in names if not. + /// + public string ColorTemperaturePresetName => + Common.Utils.VcpNames.GetFormattedValueName(0x14, _monitor.CurrentColorTemperature, _mainViewModel?.CustomVcpMappings, _monitor.Id); + + /// + /// Gets a value indicating whether this monitor supports color temperature via VCP 0x14 + /// + public bool SupportsColorTemperature => _monitor.SupportsColorTemperature; + + private List? _availableColorPresets; + private bool _showColorTemperature; + + /// + /// Gets or sets a value indicating whether to show color temperature switcher (controlled by Settings UI, default false). + /// + public bool ShowColorTemperature + { + get => _showColorTemperature && SupportsColorTemperature; + set + { + if (_showColorTemperature != value) + { + _showColorTemperature = value; + OnPropertyChanged(); + } + } + } + + /// + /// Gets available color temperature presets for this monitor + /// + public List? AvailableColorPresets + { + get + { + if (_availableColorPresets == null && SupportsColorTemperature) + { + RefreshAvailableColorPresets(); + } + + return _availableColorPresets; + } + } + + /// + /// Standard MCCS color temperature presets (VCP 0x14 values) to use as fallback + /// when the monitor doesn't report discrete values in its capabilities string. + /// + private static readonly int[] StandardColorTemperaturePresets = { 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x08, 0x09, 0x0A, 0x0B }; + + /// + /// Refresh the list of available color temperature presets based on monitor capabilities + /// + private void RefreshAvailableColorPresets() + { + if (!SupportsColorTemperature) + { + _availableColorPresets = null; + return; + } + + IEnumerable presetValues; + var vcpInfo = VcpCapabilitiesInfo; + + // Try to get discrete values from capabilities string + if (vcpInfo != null && + vcpInfo.SupportedVcpCodes.TryGetValue(0x14, out var colorTempInfo) && + colorTempInfo.HasDiscreteValues && + colorTempInfo.SupportedValues.Count > 0) + { + // Use values from capabilities string + presetValues = colorTempInfo.SupportedValues; + } + else + { + // Fallback to standard MCCS presets when capabilities don't list discrete values + presetValues = StandardColorTemperaturePresets; + } + + _availableColorPresets = presetValues.Select(value => new ColorTemperatureItem + { + VcpValue = value, + DisplayName = Common.Utils.VcpNames.GetFormattedValueName(0x14, value, _mainViewModel?.CustomVcpMappings, _monitor.Id), + IsSelected = value == _monitor.CurrentColorTemperature, + MonitorId = _monitor.Id, + }).ToList(); + + OnPropertyChanged(nameof(AvailableColorPresets)); + } + + /// + /// Gets a value indicating whether this monitor supports input source switching via VCP 0x60 + /// + public bool SupportsInputSource => _monitor.SupportsInputSource; + + /// + /// Gets current input source VCP value (from VCP code 0x60) + /// + public int CurrentInputSource => _monitor.CurrentInputSource; + + /// + /// Gets human-readable current input source name (e.g., "HDMI-1", "DisplayPort-1") + /// Uses custom mappings if available; falls back to built-in names if not. + /// + public string CurrentInputSourceName => + Common.Utils.VcpNames.GetValueName(0x60, _monitor.CurrentInputSource, _mainViewModel?.CustomVcpMappings, _monitor.Id) + ?? $"Source 0x{_monitor.CurrentInputSource:X2}"; + + private List? _availableInputSources; + + /// + /// Gets available input sources for this monitor + /// + public List? AvailableInputSources + { + get + { + if (_availableInputSources == null && SupportsInputSource) + { + RefreshAvailableInputSources(); + } + + return _availableInputSources; + } + } + + /// + /// Refresh the list of available input sources based on monitor capabilities + /// + private void RefreshAvailableInputSources() + { + var supportedSources = _monitor.SupportedInputSources; + if (supportedSources == null || supportedSources.Count == 0) + { + _availableInputSources = null; + return; + } + + _availableInputSources = supportedSources.Select(value => new InputSourceItem + { + Value = value, + Name = Common.Utils.VcpNames.GetValueName(0x60, value, _mainViewModel?.CustomVcpMappings, _monitor.Id) ?? $"Source 0x{value:X2}", + SelectionVisibility = value == _monitor.CurrentInputSource ? Visibility.Visible : Visibility.Collapsed, + MonitorId = _monitor.Id, + }).ToList(); + + OnPropertyChanged(nameof(AvailableInputSources)); + } + + /// + /// Refresh custom VCP name displays after settings change. + /// Called when CustomVcpMappings is updated from Settings UI. + /// + public void RefreshCustomVcpNames() + { + // Refresh color temperature names + OnPropertyChanged(nameof(ColorTemperaturePresetName)); + _availableColorPresets = null; // Force rebuild with new custom names + OnPropertyChanged(nameof(AvailableColorPresets)); + + // Refresh input source names + OnPropertyChanged(nameof(CurrentInputSourceName)); + _availableInputSources = null; // Force rebuild with new custom names + OnPropertyChanged(nameof(AvailableInputSources)); + } + + /// + /// Set input source for this monitor + /// + public async Task SetInputSourceAsync(int inputSource) + { + try + { + var result = await _monitorManager.SetInputSourceAsync(Id, inputSource); + + if (result.IsSuccess) + { + OnPropertyChanged(nameof(CurrentInputSource)); + OnPropertyChanged(nameof(CurrentInputSourceName)); + RefreshAvailableInputSources(); + } + else + { + Logger.LogWarning($"[{Id}] Failed to set input source: {result.ErrorMessage}"); + } + } + catch (Exception ex) + { + Logger.LogError($"[{Id}] Exception setting input source: {ex.Message}"); + } + } + + /// + /// Command to set input source + /// + [RelayCommand] + private async Task SetInputSource(int? source) + { + if (source.HasValue) + { + await SetInputSourceAsync(source.Value); + } + } + + /// + /// Gets a value indicating whether this monitor supports power state control via VCP 0xD6 + /// + public bool SupportsPowerState => _monitor.SupportsPowerState; + + private List? _availablePowerStates; + + /// + /// Gets available power states for this monitor. + /// The current power state is shown as selected based on the monitor's actual state. + /// + public List? AvailablePowerStates + { + get + { + if (_availablePowerStates == null && SupportsPowerState) + { + RefreshAvailablePowerStates(); + } + + return _availablePowerStates; + } + } + + /// + /// Refresh the list of available power states based on monitor capabilities + /// + private void RefreshAvailablePowerStates() + { + var supportedStates = _monitor.SupportedPowerStates; + if (supportedStates == null || supportedStates.Count == 0) + { + _availablePowerStates = null; + return; + } + + _availablePowerStates = supportedStates.Select(value => new PowerStateItem + { + Value = value, + Name = Common.Utils.VcpNames.GetValueName(0xD6, value) ?? $"State 0x{value:X2}", + IsSelected = value == _monitor.CurrentPowerState, + MonitorId = _monitor.Id, + }).ToList(); + + OnPropertyChanged(nameof(AvailablePowerStates)); + } + + /// + /// Set power state for this monitor. + /// Note: Setting any state other than "On" will turn off the display. + /// + public async Task SetPowerStateAsync(int powerState) + { + try + { + var result = await _monitorManager.SetPowerStateAsync(Id, powerState); + + if (result.IsSuccess) + { + // Update the model's power state and refresh UI + _monitor.CurrentPowerState = powerState; + RefreshAvailablePowerStates(); + } + else + { + Logger.LogWarning($"[{Id}] Failed to set power state: {result.ErrorMessage}"); + } + } + catch (Exception ex) + { + Logger.LogError($"[{Id}] Exception setting power state: {ex.Message}"); + } + } + + /// + /// Command to set power state + /// + [RelayCommand] + private async Task SetPowerState(int? state) + { + if (state.HasValue) + { + await SetPowerStateAsync(state.Value); + } + } + + public int Contrast + { + get => _contrast; + set + { + if (_contrast != value) + { + _ = SetContrastAsync(value); + } + } + } + + public int Volume + { + get => _volume; + set + { + if (_volume != value) + { + _ = SetVolumeAsync(value); + } + } + } + + public bool IsAvailable + { + get => _isAvailable; + set + { + _isAvailable = value; + OnPropertyChanged(); + } + } + + [RelayCommand] + private void SetBrightness(int? brightness) + { + if (brightness.HasValue) + { + Brightness = brightness.Value; + } + } + + [RelayCommand] + private void SetContrast(int? contrast) + { + if (contrast.HasValue) + { + Contrast = contrast.Value; + } + } + + [RelayCommand] + private void SetVolume(int? volume) + { + if (volume.HasValue) + { + Volume = volume.Value; + } + } + + public int ContrastPercent + { + get => MapToPercent(_contrast, MinContrast, MaxContrast); + set + { + var actualValue = MapFromPercent(value, MinContrast, MaxContrast); + Contrast = actualValue; + } + } + + // Mapping functions for percentage conversion + private int MapToPercent(int value, int min, int max) + { + if (max <= min) + { + return 0; + } + + return (int)Math.Round((value - min) * 100.0 / (max - min)); + } + + private int MapFromPercent(int percent, int min, int max) + { + if (max <= min) + { + return min; + } + + percent = Math.Clamp(percent, 0, 100); + return min + (int)Math.Round(percent * (max - min) / 100.0); + } + + public event PropertyChangedEventHandler? PropertyChanged; + + protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + + private void OnMainViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(MainViewModel.IsInteractionEnabled)) + { + OnPropertyChanged(nameof(IsInteractionEnabled)); + } + else if (e.PropertyName == nameof(MainViewModel.HasMonitors)) + { + // Monitor count changed, update display name to show/hide number suffix + OnPropertyChanged(nameof(DisplayName)); + } + } + + private void OnMonitorPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + // Forward Orientation changes from underlying Monitor to ViewModel properties + // This is important for mirror mode where MonitorManager.RefreshAllOrientations() + // updates multiple monitors sharing the same GdiDeviceName + if (e.PropertyName == nameof(Monitor.Orientation)) + { + OnPropertyChanged(nameof(CurrentRotation)); + OnPropertyChanged(nameof(IsRotation0)); + OnPropertyChanged(nameof(IsRotation1)); + OnPropertyChanged(nameof(IsRotation2)); + OnPropertyChanged(nameof(IsRotation3)); + } + } + + public void Dispose() + { + // Unsubscribe from MainViewModel events + if (_mainViewModel != null) + { + _mainViewModel.PropertyChanged -= OnMainViewModelPropertyChanged; + } + + // Unsubscribe from underlying Monitor events + _monitor.PropertyChanged -= OnMonitorPropertyChanged; + + GC.SuppressFinalize(this); + } +} diff --git a/src/modules/powerdisplay/PowerDisplay/ViewModels/PowerStateItem.cs b/src/modules/powerdisplay/PowerDisplay/ViewModels/PowerStateItem.cs new file mode 100644 index 0000000000..6be02e8d7f --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/ViewModels/PowerStateItem.cs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.UI.Xaml; + +namespace PowerDisplay.ViewModels; + +/// +/// Represents a power state option for display in UI. +/// VCP 0xD6 values: 0x01=On, 0x02=Standby, 0x03=Suspend, 0x04=Off(DPM), 0x05=Off(Hard) +/// +public class PowerStateItem +{ + /// + /// VCP power mode value representing On state + /// + public const int PowerStateOn = 0x01; + + /// + /// VCP value for this power state + /// + public int Value { get; set; } + + /// + /// Human-readable name (e.g., "On", "Standby", "Off (DPM)") + /// + public string Name { get; set; } = string.Empty; + + /// + /// Gets or sets whether this power state is currently selected. + /// Set based on monitor's actual power state during list creation. + /// + public bool IsSelected { get; set; } + + /// + /// Visibility of selection indicator (Visible when IsSelected is true) + /// + public Visibility SelectionVisibility => IsSelected ? Visibility.Visible : Visibility.Collapsed; + + /// + /// Monitor ID for direct lookup (Flyout popup is not in visual tree) + /// + public string MonitorId { get; set; } = string.Empty; +} diff --git a/src/modules/powerdisplay/PowerDisplay/app.manifest b/src/modules/powerdisplay/PowerDisplay/app.manifest new file mode 100644 index 0000000000..8a5a071870 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/app.manifest @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + true/PM + PerMonitorV2, PerMonitor + + + \ No newline at end of file diff --git a/src/modules/powerdisplay/PowerDisplayModuleInterface/PowerDisplayModuleInterface.rc b/src/modules/powerdisplay/PowerDisplayModuleInterface/PowerDisplayModuleInterface.rc new file mode 100644 index 0000000000..2f225053a0 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplayModuleInterface/PowerDisplayModuleInterface.rc @@ -0,0 +1,97 @@ +// Microsoft Visual C++ generated resource script. +// +#include +#include "resource.h" +#include "../../../common/version/version.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +1 VERSIONINFO +FILEVERSION FILE_VERSION +PRODUCTVERSION PRODUCT_VERSION +FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG +FILEFLAGS VS_FF_DEBUG +#else +FILEFLAGS 0x0L +#endif +FILEOS VOS_NT_WINDOWS32 +FILETYPE VFT_DLL +FILESUBTYPE VFT2_UNKNOWN +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904b0" // US English (0x0409), Unicode (0x04B0) charset + BEGIN + VALUE "CompanyName", COMPANY_NAME + VALUE "FileDescription", FILE_DESCRIPTION + VALUE "FileVersion", FILE_VERSION_STRING + VALUE "InternalName", INTERNAL_NAME + VALUE "LegalCopyright", COPYRIGHT_NOTE + VALUE "OriginalFilename", ORIGINAL_FILENAME + VALUE "ProductName", PRODUCT_NAME + VALUE "ProductVersion", PRODUCT_VERSION_STRING + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1200 // US English (0x0409), Unicode (1200) charset + END +END + + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US +#pragma code_page(1252) + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED + diff --git a/src/modules/powerdisplay/PowerDisplayModuleInterface/PowerDisplayModuleInterface.vcxproj b/src/modules/powerdisplay/PowerDisplayModuleInterface/PowerDisplayModuleInterface.vcxproj new file mode 100644 index 0000000000..6c68d0e291 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplayModuleInterface/PowerDisplayModuleInterface.vcxproj @@ -0,0 +1,133 @@ + + + + + + Debug + ARM64 + + + Release + ARM64 + + + Debug + x64 + + + Release + x64 + + + + 16.0 + {D1234567-8901-2345-6789-ABCDEF012345} + Win32Proj + PowerDisplayModuleInterface + + + + DynamicLibrary + true + v143 + Unicode + + + DynamicLibrary + false + v143 + true + Unicode + + + + + + + + + + + + ..\..\..\..\$(Platform)\$(Configuration)\ + $(Platform)\$(Configuration)\PowerDisplayModuleInterface\ + PowerToys.PowerDisplayModuleInterface + + + + Level3 + true + _DEBUG;POWERDISPLAYMODULEINTERFACE_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions) + true + ..\..\..\common\inc;..\..\..\common\Telemetry;..\..\;..\..\..\;%(AdditionalIncludeDirectories) + + + Windows + true + false + Shlwapi.lib;Rpcrt4.lib;$(CoreLibraryDependencies);%(AdditionalDependencies) + + + + + Level3 + true + true + true + NDEBUG;POWERDISPLAYMODULEINTERFACE_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions) + true + ..\..\..\common\inc;..\..\..\common\Telemetry;..\..\;..\..\..\;%(AdditionalIncludeDirectories) + + + Windows + true + true + true + false + Shlwapi.lib;Rpcrt4.lib;$(CoreLibraryDependencies);%(AdditionalDependencies) + + + + + + + + + + + + + Create + + + + + + + {d9b8fc84-322a-4f9f-bbb9-20915c47ddfd} + + + {6955446d-23f7-4023-9bb3-8657f904af99} + + + + + + + + + + + + + + + + + This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. + + + + + + \ No newline at end of file diff --git a/src/modules/powerdisplay/PowerDisplayModuleInterface/PowerDisplayModuleInterface.vcxproj.filters b/src/modules/powerdisplay/PowerDisplayModuleInterface/PowerDisplayModuleInterface.vcxproj.filters new file mode 100644 index 0000000000..0872553d99 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplayModuleInterface/PowerDisplayModuleInterface.vcxproj.filters @@ -0,0 +1,53 @@ +ο»Ώ + + + + {4FC737F1-C7A5-4376-A066-2A32D752A2FF} + cpp;c;cc;cxx;c++;cppm;ixx;def;odl;idl;hpj;bat;asm;asmx + + + {93995380-89BD-4b04-88EB-625FBE52EBFB} + h;hh;hpp;hxx;h++;hm;inl;inc;ipp;xsd + + + {67DA6AB6-F800-4c08-8B7A-83BB121AAD01} + rc;ico;cur;bmp;dlg;rc2;rct;bin;rgs;gif;jpg;jpeg;jpe;resx;tiff;tif;png;wav;mfcribbon-ms + + + + + Header Files + + + Header Files + + + Header Files + + + Header Files + + + + + Source Files + + + Source Files + + + Source Files + + + + + Resource Files + + + + + + + + + \ No newline at end of file diff --git a/src/modules/powerdisplay/PowerDisplayModuleInterface/PowerDisplayProcessManager.cpp b/src/modules/powerdisplay/PowerDisplayModuleInterface/PowerDisplayProcessManager.cpp new file mode 100644 index 0000000000..cf9ab171c2 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplayModuleInterface/PowerDisplayProcessManager.cpp @@ -0,0 +1,285 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +#include "pch.h" +#include "PowerDisplayProcessManager.h" + +#include +#include +#include +#include + +namespace +{ + std::optional get_pipe_name(const std::wstring& prefix) + { + UUID temp_uuid; + wchar_t* uuid_chars = nullptr; + if (UuidCreate(&temp_uuid) == RPC_S_UUID_NO_ADDRESS) + { + const auto val = get_last_error_message(GetLastError()); + Logger::error(L"UuidCreate cannot create guid. {}", val.has_value() ? val.value() : L""); + return std::nullopt; + } + else if (UuidToString(&temp_uuid, reinterpret_cast(&uuid_chars)) != RPC_S_OK) + { + const auto val = get_last_error_message(GetLastError()); + Logger::error(L"UuidToString cannot convert to string. {}", val.has_value() ? val.value() : L""); + return std::nullopt; + } + + const auto pipe_name = std::format(L"{}{}", prefix, std::wstring(uuid_chars)); + RpcStringFree(reinterpret_cast(&uuid_chars)); + + return pipe_name; + } +} + +void PowerDisplayProcessManager::start() +{ + m_enabled = true; + submit_task([this]() { refresh(); }); +} + +void PowerDisplayProcessManager::stop() +{ + m_enabled = false; + submit_task([this]() { refresh(); }); +} + +void PowerDisplayProcessManager::send_message(const std::wstring& message_type, const std::wstring& message_arg) +{ + submit_task([this, message_type, message_arg] { + // Ensure process is running before sending message + // If process is not running, enable and start it - this allows Quick Access launch + // to work even when the module was not previously enabled + if (!is_process_running()) + { + m_enabled = true; + refresh(); + } + send_named_pipe_message(message_type, message_arg); + }); +} + +void PowerDisplayProcessManager::bring_to_front() +{ + submit_task([this] { + if (!is_process_running()) + { + return; + } + + const auto enum_windows = [](HWND hwnd, LPARAM param) -> BOOL { + const auto process_handle = reinterpret_cast(param); + DWORD window_process_id = 0; + + GetWindowThreadProcessId(hwnd, &window_process_id); + if (GetProcessId(process_handle) == window_process_id) + { + SetForegroundWindow(hwnd); + return FALSE; + } + return TRUE; + }; + + EnumWindows(enum_windows, reinterpret_cast(m_hProcess)); + }); +} + +bool PowerDisplayProcessManager::is_running() const +{ + return is_process_running(); +} + +void PowerDisplayProcessManager::submit_task(std::function task) +{ + m_thread_executor.submit(OnThreadExecutor::task_t{ task }); +} + +bool PowerDisplayProcessManager::is_process_running() const +{ + return m_hProcess != 0 && WaitForSingleObject(m_hProcess, 0) == WAIT_TIMEOUT; +} + +void PowerDisplayProcessManager::terminate_process() +{ + if (m_hProcess != 0) + { + TerminateProcess(m_hProcess, 1); + CloseHandle(m_hProcess); + m_hProcess = 0; + } +} + +HRESULT PowerDisplayProcessManager::start_process(const std::wstring& pipe_name) +{ + const unsigned long powertoys_pid = GetCurrentProcessId(); + + // Pass both PID and pipe name as arguments + const auto executable_args = std::format(L"{} {}", std::to_wstring(powertoys_pid), pipe_name); + + SHELLEXECUTEINFOW sei{ sizeof(sei) }; + sei.fMask = { SEE_MASK_NOCLOSEPROCESS | SEE_MASK_FLAG_NO_UI }; + sei.lpFile = L"WinUI3Apps\\PowerToys.PowerDisplay.exe"; + sei.nShow = SW_SHOWNORMAL; + sei.lpParameters = executable_args.data(); + if (ShellExecuteExW(&sei)) + { + Logger::trace("Successfully started PowerDisplay process"); + terminate_process(); + m_hProcess = sei.hProcess; + return S_OK; + } + else + { + Logger::error(L"PowerDisplay process failed to start. {}", get_last_error_or_default(GetLastError())); + return E_FAIL; + } +} + +HRESULT PowerDisplayProcessManager::start_named_pipe_server(const std::wstring& pipe_name) +{ + m_write_pipe = nullptr; + + const constexpr DWORD BUFSIZE = 4096 * 4; + + const auto full_pipe_name = std::format(L"\\\\.\\pipe\\{}", pipe_name); + + const auto hPipe = CreateNamedPipe( + full_pipe_name.c_str(), // pipe name + PIPE_ACCESS_OUTBOUND | // write access + FILE_FLAG_OVERLAPPED, // overlapped mode + PIPE_TYPE_MESSAGE | // message type pipe + PIPE_READMODE_MESSAGE | // message-read mode + PIPE_WAIT, // blocking mode + 1, // max. instances + BUFSIZE, // output buffer size + 0, // input buffer size + 0, // client time-out + NULL); // default security attribute + + if (hPipe == NULL || hPipe == INVALID_HANDLE_VALUE) + { + Logger::error(L"Error creating handle for named pipe"); + return E_FAIL; + } + + // Create overlapped event to wait for client to connect to pipe. + OVERLAPPED overlapped = { 0 }; + overlapped.hEvent = CreateEvent(nullptr, true, false, nullptr); + if (!overlapped.hEvent) + { + Logger::error(L"Error creating overlapped event for named pipe"); + CloseHandle(hPipe); + return E_FAIL; + } + + const auto clean_up_and_fail = [&]() { + CloseHandle(overlapped.hEvent); + CloseHandle(hPipe); + return E_FAIL; + }; + + if (!ConnectNamedPipe(hPipe, &overlapped)) + { + const auto lastError = GetLastError(); + + if (lastError != ERROR_IO_PENDING && lastError != ERROR_PIPE_CONNECTED) + { + Logger::error(L"Error connecting to named pipe"); + return clean_up_and_fail(); + } + } + + // Wait for client. + const constexpr DWORD client_timeout_millis = 5000; + switch (WaitForSingleObject(overlapped.hEvent, client_timeout_millis)) + { + case WAIT_OBJECT_0: + { + DWORD bytes_transferred = 0; + if (GetOverlappedResult(hPipe, &overlapped, &bytes_transferred, FALSE)) + { + CloseHandle(overlapped.hEvent); + m_write_pipe = std::make_unique(hPipe); + + Logger::trace(L"PowerDisplay successfully connected to named pipe"); + + return S_OK; + } + else + { + Logger::error(L"Error waiting for PowerDisplay to connect to named pipe"); + return clean_up_and_fail(); + } + } + + case WAIT_TIMEOUT: + case WAIT_FAILED: + default: + Logger::error(L"Error waiting for PowerDisplay to connect to named pipe"); + return clean_up_and_fail(); + } +} + +void PowerDisplayProcessManager::refresh() +{ + if (m_enabled == is_process_running()) + { + return; + } + + if (m_enabled) + { + Logger::trace(L"Starting PowerDisplay process"); + + const auto pipe_name = get_pipe_name(L"powertoys_power_display_"); + + if (!pipe_name) + { + return; + } + + if (start_process(pipe_name.value()) != S_OK) + { + return; + } + + if (start_named_pipe_server(pipe_name.value()) != S_OK) + { + Logger::error(L"Named pipe initialization failed; terminating PowerDisplay process"); + terminate_process(); + } + } + else + { + Logger::trace(L"Exiting PowerDisplay process"); + + send_named_pipe_message(CommonSharedConstants::POWER_DISPLAY_TERMINATE_APP_MESSAGE); + WaitForSingleObject(m_hProcess, 5000); + + if (is_process_running()) + { + Logger::error(L"PowerDisplay process failed to gracefully exit; terminating"); + } + else + { + Logger::trace(L"PowerDisplay process successfully exited"); + } + + terminate_process(); + } +} + +void PowerDisplayProcessManager::send_named_pipe_message(const std::wstring& message_type, const std::wstring& message_arg) +{ + if (m_write_pipe) + { + const auto message = message_arg.empty() ? std::format(L"{}\r\n", message_type) : std::format(L"{} {}\r\n", message_type, message_arg); + + const CString file_name(message.c_str()); + m_write_pipe->Write(file_name, file_name.GetLength() * sizeof(TCHAR)); + } +} diff --git a/src/modules/powerdisplay/PowerDisplayModuleInterface/PowerDisplayProcessManager.h b/src/modules/powerdisplay/PowerDisplayModuleInterface/PowerDisplayProcessManager.h new file mode 100644 index 0000000000..98e31918b3 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplayModuleInterface/PowerDisplayProcessManager.h @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +#pragma once +#include "pch.h" +#include +#include +#include +#include +#include +#include + +/// +/// Manages the PowerDisplay.exe process and Named Pipe communication. +/// Based on AdvancedPasteProcessManager pattern. +/// +class PowerDisplayProcessManager +{ +public: + PowerDisplayProcessManager() = default; + PowerDisplayProcessManager(const PowerDisplayProcessManager&) = delete; + PowerDisplayProcessManager& operator=(const PowerDisplayProcessManager&) = delete; + + /// + /// Enable the module - starts the PowerDisplay.exe process. + /// + void start(); + + /// + /// Disable the module - terminates the PowerDisplay.exe process. + /// + void stop(); + + /// + /// Send a message to PowerDisplay.exe via Named Pipe. + /// + /// The message type (e.g., "Toggle", "ApplyProfile") + /// Optional message argument + void send_message(const std::wstring& message_type, const std::wstring& message_arg = L""); + + /// + /// Bring the PowerDisplay window to the foreground. + /// + void bring_to_front(); + + /// + /// Check if PowerDisplay.exe process is running. + /// + bool is_running() const; + +private: + void submit_task(std::function task); + bool is_process_running() const; + void terminate_process(); + HRESULT start_process(const std::wstring& pipe_name); + HRESULT start_named_pipe_server(const std::wstring& pipe_name); + void refresh(); + void send_named_pipe_message(const std::wstring& message_type, const std::wstring& message_arg = L""); + + OnThreadExecutor m_thread_executor; // all internal operations are done on background thread with task queue + std::atomic m_enabled = false; // written on main thread, read on background thread + HANDLE m_hProcess = 0; + std::unique_ptr m_write_pipe; +}; diff --git a/src/modules/powerdisplay/PowerDisplayModuleInterface/Trace.cpp b/src/modules/powerdisplay/PowerDisplayModuleInterface/Trace.cpp new file mode 100644 index 0000000000..3ac410724b --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplayModuleInterface/Trace.cpp @@ -0,0 +1,32 @@ +#include "pch.h" +#include "trace.h" + +#include + +TRACELOGGING_DEFINE_PROVIDER( + g_hProvider, + "Microsoft.PowerToys", + // {38e8889b-9731-53f5-e901-e8a7c1753074} + (0x38e8889b, 0x9731, 0x53f5, 0xe9, 0x01, 0xe8, 0xa7, 0xc1, 0x75, 0x30, 0x74), + TraceLoggingOptionProjectTelemetry()); + +// Log if the user has enabled or disabled the app +void Trace::EnablePowerDisplay(_In_ bool enabled) noexcept +{ + TraceLoggingWriteWrapper( + g_hProvider, + "PowerDisplay_EnablePowerDisplay", + ProjectTelemetryPrivacyDataTag(ProjectTelemetryTag_ProductAndServicePerformance), + TraceLoggingKeyword(PROJECT_KEYWORD_MEASURE), + TraceLoggingBoolean(enabled, "Enabled")); +} + +// Log that the user tried to activate the app +void Trace::ActivatePowerDisplay() noexcept +{ + TraceLoggingWriteWrapper( + g_hProvider, + "PowerDisplay_Activate", + ProjectTelemetryPrivacyDataTag(ProjectTelemetryTag_ProductAndServicePerformance), + TraceLoggingKeyword(PROJECT_KEYWORD_MEASURE)); +} diff --git a/src/modules/powerdisplay/PowerDisplayModuleInterface/Trace.h b/src/modules/powerdisplay/PowerDisplayModuleInterface/Trace.h new file mode 100644 index 0000000000..c650cfb346 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplayModuleInterface/Trace.h @@ -0,0 +1,13 @@ +#pragma once + +#include + +class Trace : public telemetry::TraceBase +{ +public: + // Log if the user has enabled or disabled the app + static void EnablePowerDisplay(const bool enabled) noexcept; + + // Log that the user tried to activate the app + static void ActivatePowerDisplay() noexcept; +}; diff --git a/src/modules/powerdisplay/PowerDisplayModuleInterface/dllmain.cpp b/src/modules/powerdisplay/PowerDisplayModuleInterface/dllmain.cpp new file mode 100644 index 0000000000..7360a34772 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplayModuleInterface/dllmain.cpp @@ -0,0 +1,358 @@ +// dllmain.cpp : Defines the entry point for the DLL Application. +#include "pch.h" +#include +#include +#include "trace.h" +#include "PowerDisplayProcessManager.h" +#include +#include +#include +#include +#include +#include +#include + +#include "resource.h" + +extern "C" IMAGE_DOS_HEADER __ImageBase; + +BOOL APIENTRY DllMain(HMODULE /*hModule*/, DWORD ul_reason_for_call, LPVOID /*lpReserved*/) +{ + switch (ul_reason_for_call) + { + case DLL_PROCESS_ATTACH: + Trace::RegisterProvider(); + break; + case DLL_THREAD_ATTACH: + case DLL_THREAD_DETACH: + break; + case DLL_PROCESS_DETACH: + Trace::UnregisterProvider(); + break; + } + return TRUE; +} + +const static wchar_t* MODULE_NAME = L"PowerDisplay"; +const static wchar_t* MODULE_DESC = L"A utility to manage display brightness and color temperature across multiple monitors."; + +class PowerDisplayModule : public PowertoyModuleIface +{ +private: + bool m_enabled = false; + + // Process manager handles Named Pipe communication and process lifecycle + PowerDisplayProcessManager m_processManager; + + // Windows Events for Settings UI triggered events (these are still needed) + // Note: These events are created on-demand by EventHelper.SignalEvent() in Settings UI + // and NativeEventWaiter.WaitForEventLoop() in PowerDisplay.exe. + HANDLE m_hRefreshEvent = nullptr; + HANDLE m_hSendSettingsTelemetryEvent = nullptr; + + // Toggle event handle and listener thread for Quick Access support + HANDLE m_hToggleEvent = nullptr; + HANDLE m_hStopEvent = nullptr; // Manual-reset event to signal thread termination + std::thread m_toggleEventThread; + +public: + PowerDisplayModule() + { + LoggerHelpers::init_logger(MODULE_NAME, L"ModuleInterface", LogSettings::powerDisplayLoggerName); + Logger::info("Power Display module is constructing"); + + // Create Windows Events for Settings UI triggered operations + // These events are signaled by Settings UI, not by module DLL + Logger::trace(L"Creating Windows Events for Settings UI IPC..."); + m_hRefreshEvent = CreateDefaultEvent(CommonSharedConstants::REFRESH_POWER_DISPLAY_MONITORS_EVENT); + Logger::trace(L"Created REFRESH_MONITORS_EVENT: handle={}", reinterpret_cast(m_hRefreshEvent)); + m_hSendSettingsTelemetryEvent = CreateDefaultEvent(CommonSharedConstants::POWER_DISPLAY_SEND_SETTINGS_TELEMETRY_EVENT); + Logger::trace(L"Created SEND_SETTINGS_TELEMETRY_EVENT: handle={}", reinterpret_cast(m_hSendSettingsTelemetryEvent)); + + // Create Toggle event for Quick Access support + // This allows Quick Access to launch PowerDisplay even when module is not enabled + m_hToggleEvent = CreateDefaultEvent(CommonSharedConstants::TOGGLE_POWER_DISPLAY_EVENT); + Logger::trace(L"Created TOGGLE_EVENT: handle={}", reinterpret_cast(m_hToggleEvent)); + + // Create manual-reset stop event for clean thread termination + m_hStopEvent = CreateEventW(nullptr, TRUE, FALSE, nullptr); + Logger::trace(L"Created STOP_EVENT: handle={}", reinterpret_cast(m_hStopEvent)); + + if (!m_hRefreshEvent || !m_hSendSettingsTelemetryEvent || !m_hToggleEvent || !m_hStopEvent) + { + Logger::error(L"Failed to create one or more event handles: Refresh={}, SettingsTelemetry={}, Toggle={}", + reinterpret_cast(m_hRefreshEvent), + reinterpret_cast(m_hSendSettingsTelemetryEvent), + reinterpret_cast(m_hToggleEvent)); + } + else + { + Logger::info(L"All Windows Events created successfully"); + } + + // Start toggle event listener thread for Quick Access support + StartToggleEventListener(); + } + + ~PowerDisplayModule() + { + if (m_enabled) + { + disable(); + } + + // Stop toggle event listener thread + StopToggleEventListener(); + + // Clean up event handles + if (m_hRefreshEvent) + { + CloseHandle(m_hRefreshEvent); + m_hRefreshEvent = nullptr; + } + if (m_hSendSettingsTelemetryEvent) + { + CloseHandle(m_hSendSettingsTelemetryEvent); + m_hSendSettingsTelemetryEvent = nullptr; + } + if (m_hToggleEvent) + { + CloseHandle(m_hToggleEvent); + m_hToggleEvent = nullptr; + } + if (m_hStopEvent) + { + CloseHandle(m_hStopEvent); + m_hStopEvent = nullptr; + } + } + + void StartToggleEventListener() + { + if (!m_hToggleEvent || !m_hStopEvent) + { + return; + } + + // Reset stop event before starting thread + ResetEvent(m_hStopEvent); + + m_toggleEventThread = std::thread([this]() { + Logger::info(L"Toggle event listener thread started"); + + HANDLE handles[] = { m_hToggleEvent, m_hStopEvent }; + constexpr DWORD TOGGLE_EVENT_INDEX = 0; + constexpr DWORD STOP_EVENT_INDEX = 1; + + while (true) + { + // Wait indefinitely for either toggle event or stop event + DWORD result = WaitForMultipleObjects(2, handles, FALSE, INFINITE); + + if (result == WAIT_OBJECT_0 + TOGGLE_EVENT_INDEX) + { + Logger::trace(L"Toggle event received"); + TogglePowerDisplay(); + } + else if (result == WAIT_OBJECT_0 + STOP_EVENT_INDEX) + { + // Stop event signaled - exit the loop + Logger::trace(L"Stop event received, exiting toggle listener"); + break; + } + else + { + // WAIT_FAILED or unexpected result + Logger::warn(L"WaitForMultipleObjects returned unexpected result: {}", result); + break; + } + } + + Logger::info(L"Toggle event listener thread stopped"); + }); + } + + void StopToggleEventListener() + { + if (m_hStopEvent) + { + // Signal the stop event to wake up the waiting thread + SetEvent(m_hStopEvent); + } + + if (m_toggleEventThread.joinable()) + { + m_toggleEventThread.join(); + } + } + + /// + /// Toggle PowerDisplay window visibility. + /// If process is running, launches again to trigger redirect activation (OnActivated handles toggle). + /// If process is not running, starts it via Named Pipe and sends toggle message. + /// + void TogglePowerDisplay() + { + if (m_processManager.is_running()) + { + // Process running - launch to trigger single instance redirect, OnActivated will toggle + SHELLEXECUTEINFOW sei{ sizeof(sei) }; + sei.fMask = SEE_MASK_FLAG_NO_UI; + sei.lpFile = L"WinUI3Apps\\PowerToys.PowerDisplay.exe"; + sei.nShow = SW_SHOWNORMAL; + ShellExecuteExW(&sei); + } + else + { + // Process not running - start and send toggle via Named Pipe + m_processManager.send_message(CommonSharedConstants::POWER_DISPLAY_TOGGLE_MESSAGE); + } + Trace::ActivatePowerDisplay(); + } + + virtual void destroy() override + { + Logger::trace("PowerDisplay::destroy()"); + delete this; + } + + virtual const wchar_t* get_name() override + { + return MODULE_NAME; + } + + virtual const wchar_t* get_key() override + { + return MODULE_NAME; + } + + virtual powertoys_gpo::gpo_rule_configured_t gpo_policy_enabled_configuration() override + { + return powertoys_gpo::getConfiguredPowerDisplayEnabledValue(); + } + + virtual bool get_config(wchar_t* buffer, int* buffer_size) override + { + HINSTANCE hinstance = reinterpret_cast(&__ImageBase); + + PowerToysSettings::Settings settings(hinstance, get_name()); + settings.set_description(MODULE_DESC); + + return settings.serialize_to_buffer(buffer, buffer_size); + } + + virtual void call_custom_action(const wchar_t* action) override + { + try + { + PowerToysSettings::CustomActionObject action_object = + PowerToysSettings::CustomActionObject::from_json_string(action); + + if (action_object.get_name() == L"Launch") + { + Logger::trace(L"Launch action received"); + TogglePowerDisplay(); + } + else if (action_object.get_name() == L"RefreshMonitors") + { + Logger::trace(L"RefreshMonitors action received, signaling refresh event"); + if (m_hRefreshEvent) + { + SetEvent(m_hRefreshEvent); + } + else + { + Logger::warn(L"Refresh event handle is null"); + } + } + else if (action_object.get_name() == L"ApplyProfile") + { + Logger::trace(L"ApplyProfile action received"); + + // Get the profile name from the action value + std::wstring profileName = action_object.get_value(); + Logger::trace(L"ApplyProfile: profile name = '{}'", profileName); + + // Send ApplyProfile message with profile name via Named Pipe + m_processManager.send_message(CommonSharedConstants::POWER_DISPLAY_APPLY_PROFILE_MESSAGE, profileName); + } + } + catch (std::exception&) + { + Logger::error(L"Failed to parse action. {}", action); + } + } + + virtual void set_config(const wchar_t* /*config*/) override + { + // Settings changes are handled via dedicated Windows Events: + // - HotkeyUpdatedPowerDisplayEvent: triggered by Settings UI when activation shortcut changes + // - SettingsUpdatedPowerDisplayEvent: triggered for tray icon visibility changes + // PowerDisplay.exe reads settings directly from file when these events are signaled. + } + + virtual void enable() override + { + Logger::info(L"enable: PowerDisplay module is being enabled"); + m_enabled = true; + Trace::EnablePowerDisplay(true); + + // Start the process manager (launches PowerDisplay.exe with Named Pipe) + m_processManager.start(); + + Logger::info(L"enable: PowerDisplay module enabled successfully"); + } + + virtual void disable() override + { + Logger::trace(L"PowerDisplay::disable()"); + + if (m_enabled) + { + // Stop the process manager (sends terminate message and waits for exit) + m_processManager.stop(); + } + + m_enabled = false; + Trace::EnablePowerDisplay(false); + } + + virtual bool is_enabled() override + { + return m_enabled; + } + + // NOTE: Hotkey handling is done in-process by PowerDisplay.exe using RegisterHotKey, + // similar to CmdPal pattern. This avoids IPC timing issues where Deactivated event + // fires before the Toggle event arrives from Runner. + virtual bool on_hotkey(size_t /*hotkeyId*/) override + { + // PowerDisplay handles hotkeys in-process, not via Runner IPC + return false; + } + + virtual size_t get_hotkeys(Hotkey* /*hotkeys*/, size_t /*buffer_size*/) override + { + // PowerDisplay handles hotkeys in-process, not via Runner + // Return 0 to tell Runner we don't want any hotkeys registered + return 0; + } + + virtual void send_settings_telemetry() override + { + Logger::trace(L"send_settings_telemetry: Signaling settings telemetry event"); + if (m_hSendSettingsTelemetryEvent) + { + SetEvent(m_hSendSettingsTelemetryEvent); + } + else + { + Logger::warn(L"send_settings_telemetry: Event handle is null"); + } + } +}; + +extern "C" __declspec(dllexport) PowertoyModuleIface* __cdecl powertoy_create() +{ + return new PowerDisplayModule(); +} diff --git a/src/modules/powerdisplay/PowerDisplayModuleInterface/packages.config b/src/modules/powerdisplay/PowerDisplayModuleInterface/packages.config new file mode 100644 index 0000000000..ff4b059648 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplayModuleInterface/packages.config @@ -0,0 +1,5 @@ +ο»Ώ + + + + \ No newline at end of file diff --git a/src/modules/powerdisplay/PowerDisplayModuleInterface/pch.cpp b/src/modules/powerdisplay/PowerDisplayModuleInterface/pch.cpp new file mode 100644 index 0000000000..1d9f38c57d --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplayModuleInterface/pch.cpp @@ -0,0 +1 @@ +#include "pch.h" diff --git a/src/modules/powerdisplay/PowerDisplayModuleInterface/pch.h b/src/modules/powerdisplay/PowerDisplayModuleInterface/pch.h new file mode 100644 index 0000000000..9e02b6c9ce --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplayModuleInterface/pch.h @@ -0,0 +1,13 @@ +#pragma once + +#define WIN32_LEAN_AND_MEAN +#include +#include +#include +#include + +#include + +#include +#include +#include diff --git a/src/modules/powerdisplay/PowerDisplayModuleInterface/resource.h b/src/modules/powerdisplay/PowerDisplayModuleInterface/resource.h new file mode 100644 index 0000000000..86220c10fa --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplayModuleInterface/resource.h @@ -0,0 +1,13 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by PowerDisplayExt.rc + +////////////////////////////// +// Non-localizable + +#define FILE_DESCRIPTION "PowerToys PowerDisplay Module" +#define INTERNAL_NAME "PowerToys.PowerDisplay" +#define ORIGINAL_FILENAME "PowerToys.PowerDisplay.dll" + +// Non-localizable +////////////////////////////// diff --git a/src/modules/powerrename/PowerRename.FuzzingTest/PowerRename.FuzzingTest.vcxproj b/src/modules/powerrename/PowerRename.FuzzingTest/PowerRename.FuzzingTest.vcxproj index 16272dba69..e05a9797c1 100644 --- a/src/modules/powerrename/PowerRename.FuzzingTest/PowerRename.FuzzingTest.vcxproj +++ b/src/modules/powerrename/PowerRename.FuzzingTest/PowerRename.FuzzingTest.vcxproj @@ -13,7 +13,7 @@ Application false - v143 + Unicode diff --git a/src/modules/powerrename/PowerRenameContextMenu/PowerRenameContextMenu.vcxproj b/src/modules/powerrename/PowerRenameContextMenu/PowerRenameContextMenu.vcxproj index a101c28ac9..babf0b4fa7 100644 --- a/src/modules/powerrename/PowerRenameContextMenu/PowerRenameContextMenu.vcxproj +++ b/src/modules/powerrename/PowerRenameContextMenu/PowerRenameContextMenu.vcxproj @@ -14,13 +14,13 @@ DynamicLibrary true - v143 + Unicode DynamicLibrary false - v143 + true Unicode diff --git a/src/modules/powerrename/PowerRenameUILib/PowerRenameUI.vcxproj b/src/modules/powerrename/PowerRenameUILib/PowerRenameUI.vcxproj index de71eb2188..de8fb6d4ca 100644 --- a/src/modules/powerrename/PowerRenameUILib/PowerRenameUI.vcxproj +++ b/src/modules/powerrename/PowerRenameUILib/PowerRenameUI.vcxproj @@ -48,7 +48,7 @@ Application - v143 + Unicode true diff --git a/src/modules/powerrename/dll/PowerRenameExt.vcxproj b/src/modules/powerrename/dll/PowerRenameExt.vcxproj index 2364012861..b74624569c 100644 --- a/src/modules/powerrename/dll/PowerRenameExt.vcxproj +++ b/src/modules/powerrename/dll/PowerRenameExt.vcxproj @@ -16,7 +16,7 @@ ..\..\..\..\$(Platform)\$(Configuration)\WinUI3Apps\ - v143 + diff --git a/src/modules/powerrename/lib/PowerRenameLib.vcxproj b/src/modules/powerrename/lib/PowerRenameLib.vcxproj index bd5740dee7..ef52de5085 100644 --- a/src/modules/powerrename/lib/PowerRenameLib.vcxproj +++ b/src/modules/powerrename/lib/PowerRenameLib.vcxproj @@ -9,7 +9,7 @@ StaticLibrary - v143 + diff --git a/src/modules/powerrename/lib/PowerRenameRegEx.cpp b/src/modules/powerrename/lib/PowerRenameRegEx.cpp index e9ce4fa62a..266fe5af9d 100644 --- a/src/modules/powerrename/lib/PowerRenameRegEx.cpp +++ b/src/modules/powerrename/lib/PowerRenameRegEx.cpp @@ -33,23 +33,27 @@ static std::wstring SanitizeAndNormalize(const std::wstring& input) // Normalize to NFC (Precomposed). // Get the size needed for the normalized string, including null terminator. - int size = NormalizeString(NormalizationC, sanitized.c_str(), -1, nullptr, 0); - if (size <= 0) + int sizeEstimate = NormalizeString(NormalizationC, sanitized.c_str(), -1, nullptr, 0); + if (sizeEstimate <= 0) { return sanitized; // Return unaltered if normalization fails. } // Perform the normalization. std::wstring normalized; - normalized.resize(size); - NormalizeString(NormalizationC, sanitized.c_str(), -1, &normalized[0], size); + normalized.resize(sizeEstimate); + int actualSize = NormalizeString(NormalizationC, sanitized.c_str(), -1, &normalized[0], sizeEstimate); - // Remove the explicit null terminator added by NormalizeString. - if (!normalized.empty() && normalized.back() == L'\0') + if (actualSize <= 0) { - normalized.pop_back(); + // Normalization failed, return sanitized string. + return sanitized; } + // Resize to actual size minus the null terminator. + // actualSize includes the null terminator when input length is -1. + normalized.resize(static_cast(actualSize) - 1); + return normalized; } diff --git a/src/modules/powerrename/testapp/PowerRenameTest.vcxproj b/src/modules/powerrename/testapp/PowerRenameTest.vcxproj index 27d6cffab8..9e678439f4 100644 --- a/src/modules/powerrename/testapp/PowerRenameTest.vcxproj +++ b/src/modules/powerrename/testapp/PowerRenameTest.vcxproj @@ -12,7 +12,7 @@ Application - v143 + diff --git a/src/modules/powerrename/unittests/CommonRegExTests.h b/src/modules/powerrename/unittests/CommonRegExTests.h index 4dc078e9b1..392252655d 100644 --- a/src/modules/powerrename/unittests/CommonRegExTests.h +++ b/src/modules/powerrename/unittests/CommonRegExTests.h @@ -695,6 +695,38 @@ TEST_METHOD(VerifyUnicodeAndWhitespaceNormalizationRegex) VerifyNormalizationHelper(UseRegularExpressions); } +TEST_METHOD(VerifyRegexMetacharacterDollarSign) +{ + CComPtr renameRegEx; + Assert::IsTrue(CPowerRenameRegEx::s_CreateInstance(&renameRegEx) == S_OK); + DWORD flags = UseRegularExpressions; + Assert::IsTrue(renameRegEx->PutFlags(flags) == S_OK); + + PWSTR result = nullptr; + Assert::IsTrue(renameRegEx->PutSearchTerm(L"$") == S_OK); + Assert::IsTrue(renameRegEx->PutReplaceTerm(L"_end") == S_OK); + unsigned long index = {}; + Assert::IsTrue(renameRegEx->Replace(L"test.txt", &result, index) == S_OK); + Assert::AreEqual(L"test.txt_end", result); + CoTaskMemFree(result); +} + +TEST_METHOD(VerifyRegexMetacharacterCaret) +{ + CComPtr renameRegEx; + Assert::IsTrue(CPowerRenameRegEx::s_CreateInstance(&renameRegEx) == S_OK); + DWORD flags = UseRegularExpressions; + Assert::IsTrue(renameRegEx->PutFlags(flags) == S_OK); + + PWSTR result = nullptr; + Assert::IsTrue(renameRegEx->PutSearchTerm(L"^") == S_OK); + Assert::IsTrue(renameRegEx->PutReplaceTerm(L"start_") == S_OK); + unsigned long index = {}; + Assert::IsTrue(renameRegEx->Replace(L"test.txt", &result, index) == S_OK); + Assert::AreEqual(L"start_test.txt", result); + CoTaskMemFree(result); +} + #ifndef TESTS_PARTIAL }; } diff --git a/src/modules/powerrename/unittests/PowerRenameLibUnitTests.vcxproj b/src/modules/powerrename/unittests/PowerRenameLibUnitTests.vcxproj index 8e58bb7956..4b6924a370 100644 --- a/src/modules/powerrename/unittests/PowerRenameLibUnitTests.vcxproj +++ b/src/modules/powerrename/unittests/PowerRenameLibUnitTests.vcxproj @@ -9,7 +9,7 @@ - v143 + DynamicLibrary diff --git a/src/modules/previewpane/BgcodePreviewHandlerCpp/BgcodePreviewHandlerCpp.vcxproj b/src/modules/previewpane/BgcodePreviewHandlerCpp/BgcodePreviewHandlerCpp.vcxproj index 7679cccbc0..3089dc4f9a 100644 --- a/src/modules/previewpane/BgcodePreviewHandlerCpp/BgcodePreviewHandlerCpp.vcxproj +++ b/src/modules/previewpane/BgcodePreviewHandlerCpp/BgcodePreviewHandlerCpp.vcxproj @@ -11,13 +11,13 @@ DynamicLibrary true - v143 + Unicode DynamicLibrary false - v143 + true Unicode diff --git a/src/modules/previewpane/BgcodeThumbnailProviderCpp/BgcodeThumbnailProviderCpp.vcxproj b/src/modules/previewpane/BgcodeThumbnailProviderCpp/BgcodeThumbnailProviderCpp.vcxproj index f561cf53be..1ce068b2df 100644 --- a/src/modules/previewpane/BgcodeThumbnailProviderCpp/BgcodeThumbnailProviderCpp.vcxproj +++ b/src/modules/previewpane/BgcodeThumbnailProviderCpp/BgcodeThumbnailProviderCpp.vcxproj @@ -11,13 +11,13 @@ DynamicLibrary true - v143 + Unicode DynamicLibrary false - v143 + true Unicode diff --git a/src/modules/previewpane/GcodePreviewHandlerCpp/GcodePreviewHandlerCpp.vcxproj b/src/modules/previewpane/GcodePreviewHandlerCpp/GcodePreviewHandlerCpp.vcxproj index 6123215de5..45401739c5 100644 --- a/src/modules/previewpane/GcodePreviewHandlerCpp/GcodePreviewHandlerCpp.vcxproj +++ b/src/modules/previewpane/GcodePreviewHandlerCpp/GcodePreviewHandlerCpp.vcxproj @@ -11,13 +11,13 @@ DynamicLibrary true - v143 + Unicode DynamicLibrary false - v143 + true Unicode diff --git a/src/modules/previewpane/GcodeThumbnailProviderCpp/GcodeThumbnailProviderCpp.vcxproj b/src/modules/previewpane/GcodeThumbnailProviderCpp/GcodeThumbnailProviderCpp.vcxproj index 6bb6a12661..c996788e68 100644 --- a/src/modules/previewpane/GcodeThumbnailProviderCpp/GcodeThumbnailProviderCpp.vcxproj +++ b/src/modules/previewpane/GcodeThumbnailProviderCpp/GcodeThumbnailProviderCpp.vcxproj @@ -11,13 +11,13 @@ DynamicLibrary true - v143 + Unicode DynamicLibrary false - v143 + true Unicode diff --git a/src/modules/previewpane/MarkdownPreviewHandlerCpp/MarkdownPreviewHandlerCpp.vcxproj b/src/modules/previewpane/MarkdownPreviewHandlerCpp/MarkdownPreviewHandlerCpp.vcxproj index 3c42d80bc0..7ba8adda50 100644 --- a/src/modules/previewpane/MarkdownPreviewHandlerCpp/MarkdownPreviewHandlerCpp.vcxproj +++ b/src/modules/previewpane/MarkdownPreviewHandlerCpp/MarkdownPreviewHandlerCpp.vcxproj @@ -14,13 +14,13 @@ DynamicLibrary true - v143 + Unicode DynamicLibrary false - v143 + true Unicode diff --git a/src/modules/previewpane/MonacoPreviewHandlerCpp/MonacoPreviewHandlerCpp.vcxproj b/src/modules/previewpane/MonacoPreviewHandlerCpp/MonacoPreviewHandlerCpp.vcxproj index dbe3cfced4..08d322bd64 100644 --- a/src/modules/previewpane/MonacoPreviewHandlerCpp/MonacoPreviewHandlerCpp.vcxproj +++ b/src/modules/previewpane/MonacoPreviewHandlerCpp/MonacoPreviewHandlerCpp.vcxproj @@ -11,13 +11,13 @@ DynamicLibrary true - v143 + Unicode DynamicLibrary false - v143 + true Unicode diff --git a/src/modules/previewpane/PdfPreviewHandlerCpp/PdfPreviewHandlerCpp.vcxproj b/src/modules/previewpane/PdfPreviewHandlerCpp/PdfPreviewHandlerCpp.vcxproj index 2a683cc14b..084e339e5f 100644 --- a/src/modules/previewpane/PdfPreviewHandlerCpp/PdfPreviewHandlerCpp.vcxproj +++ b/src/modules/previewpane/PdfPreviewHandlerCpp/PdfPreviewHandlerCpp.vcxproj @@ -11,13 +11,13 @@ DynamicLibrary true - v143 + Unicode DynamicLibrary false - v143 + true Unicode diff --git a/src/modules/previewpane/PdfThumbnailProviderCpp/PdfThumbnailProviderCpp.vcxproj b/src/modules/previewpane/PdfThumbnailProviderCpp/PdfThumbnailProviderCpp.vcxproj index 1daebb6612..113783a4f7 100644 --- a/src/modules/previewpane/PdfThumbnailProviderCpp/PdfThumbnailProviderCpp.vcxproj +++ b/src/modules/previewpane/PdfThumbnailProviderCpp/PdfThumbnailProviderCpp.vcxproj @@ -11,13 +11,13 @@ DynamicLibrary true - v143 + Unicode DynamicLibrary false - v143 + true Unicode diff --git a/src/modules/previewpane/QoiPreviewHandlerCpp/QoiPreviewHandlerCpp.vcxproj b/src/modules/previewpane/QoiPreviewHandlerCpp/QoiPreviewHandlerCpp.vcxproj index 6bd7147154..2e156fce12 100644 --- a/src/modules/previewpane/QoiPreviewHandlerCpp/QoiPreviewHandlerCpp.vcxproj +++ b/src/modules/previewpane/QoiPreviewHandlerCpp/QoiPreviewHandlerCpp.vcxproj @@ -11,13 +11,13 @@ DynamicLibrary true - v143 + Unicode DynamicLibrary false - v143 + true Unicode diff --git a/src/modules/previewpane/QoiThumbnailProviderCpp/QoiThumbnailProviderCpp.vcxproj b/src/modules/previewpane/QoiThumbnailProviderCpp/QoiThumbnailProviderCpp.vcxproj index 8bdc0b826f..15f196ca6b 100644 --- a/src/modules/previewpane/QoiThumbnailProviderCpp/QoiThumbnailProviderCpp.vcxproj +++ b/src/modules/previewpane/QoiThumbnailProviderCpp/QoiThumbnailProviderCpp.vcxproj @@ -11,13 +11,13 @@ DynamicLibrary true - v143 + Unicode DynamicLibrary false - v143 + true Unicode diff --git a/src/modules/previewpane/StlThumbnailProviderCpp/StlThumbnailProviderCpp.vcxproj b/src/modules/previewpane/StlThumbnailProviderCpp/StlThumbnailProviderCpp.vcxproj index 202c2290d0..0e58b5999f 100644 --- a/src/modules/previewpane/StlThumbnailProviderCpp/StlThumbnailProviderCpp.vcxproj +++ b/src/modules/previewpane/StlThumbnailProviderCpp/StlThumbnailProviderCpp.vcxproj @@ -11,13 +11,13 @@ DynamicLibrary true - v143 + Unicode DynamicLibrary false - v143 + true Unicode diff --git a/src/modules/previewpane/SvgPreviewHandlerCpp/SvgPreviewHandlerCpp.vcxproj b/src/modules/previewpane/SvgPreviewHandlerCpp/SvgPreviewHandlerCpp.vcxproj index fc55a391d5..53fe7bdd53 100644 --- a/src/modules/previewpane/SvgPreviewHandlerCpp/SvgPreviewHandlerCpp.vcxproj +++ b/src/modules/previewpane/SvgPreviewHandlerCpp/SvgPreviewHandlerCpp.vcxproj @@ -11,13 +11,13 @@ DynamicLibrary true - v143 + Unicode DynamicLibrary false - v143 + true Unicode diff --git a/src/modules/previewpane/SvgThumbnailProviderCpp/SvgThumbnailProviderCpp.vcxproj b/src/modules/previewpane/SvgThumbnailProviderCpp/SvgThumbnailProviderCpp.vcxproj index 8712c6c298..ae0ea11672 100644 --- a/src/modules/previewpane/SvgThumbnailProviderCpp/SvgThumbnailProviderCpp.vcxproj +++ b/src/modules/previewpane/SvgThumbnailProviderCpp/SvgThumbnailProviderCpp.vcxproj @@ -11,13 +11,13 @@ DynamicLibrary true - v143 + Unicode DynamicLibrary false - v143 + true Unicode diff --git a/src/modules/previewpane/powerpreview/powerpreview.vcxproj b/src/modules/previewpane/powerpreview/powerpreview.vcxproj index 9b32f385c4..4a29b66dc6 100644 --- a/src/modules/previewpane/powerpreview/powerpreview.vcxproj +++ b/src/modules/previewpane/powerpreview/powerpreview.vcxproj @@ -16,7 +16,7 @@ DynamicLibrary - v143 + diff --git a/src/modules/registrypreview/RegistryPreviewExt/RegistryPreviewExt.vcxproj b/src/modules/registrypreview/RegistryPreviewExt/RegistryPreviewExt.vcxproj index e1b5064da4..02818e8615 100644 --- a/src/modules/registrypreview/RegistryPreviewExt/RegistryPreviewExt.vcxproj +++ b/src/modules/registrypreview/RegistryPreviewExt/RegistryPreviewExt.vcxproj @@ -29,13 +29,13 @@ DynamicLibrary true - v143 + Unicode DynamicLibrary false - v143 + true Unicode diff --git a/src/runner/Resources.resx b/src/runner/Resources.resx index c8eb5f25cc..b94f84714e 100644 --- a/src/runner/Resources.resx +++ b/src/runner/Resources.resx @@ -173,6 +173,10 @@ Settings\tDouble-click Don't localize "\t" as that is what separates the click portion to be right aligned in the menu. + + Settings\tLeft-click + Don't localize "\t" as that is what separates the click portion to be right aligned in the menu. This is shown when Quick Access is disabled. + Documentation diff --git a/src/runner/main.cpp b/src/runner/main.cpp index d8fdcbdb04..973cee4ba5 100644 --- a/src/runner/main.cpp +++ b/src/runner/main.cpp @@ -286,6 +286,7 @@ int runner(bool isProcessElevated, bool openSettings, std::string settingsWindow L"PowerToys.CmdPalModuleInterface.dll", L"PowerToys.ZoomItModuleInterface.dll", L"PowerToys.LightSwitchModuleInterface.dll", + L"PowerToys.PowerDisplayModuleInterface.dll", }; for (auto moduleSubdir : knownModules) diff --git a/src/runner/runner.vcxproj b/src/runner/runner.vcxproj index 23cc1c9d9f..88b74e3faa 100644 --- a/src/runner/runner.vcxproj +++ b/src/runner/runner.vcxproj @@ -32,7 +32,7 @@ Application - v143 + None true true diff --git a/src/runner/settings_window.cpp b/src/runner/settings_window.cpp index f245c0c607..e6e918e251 100644 --- a/src/runner/settings_window.cpp +++ b/src/runner/settings_window.cpp @@ -893,6 +893,8 @@ std::string ESettingsWindowNames_to_string(ESettingsWindowNames value) return "CmdPal"; case ESettingsWindowNames::ZoomIt: return "ZoomIt"; + case ESettingsWindowNames::PowerDisplay: + return "PowerDisplay"; default: { Logger::error(L"Can't convert ESettingsWindowNames value={} to string", static_cast(value)); @@ -1032,6 +1034,10 @@ ESettingsWindowNames ESettingsWindowNames_from_string(std::string value) { return ESettingsWindowNames::ZoomIt; } + else if (value == "PowerDisplay") + { + return ESettingsWindowNames::PowerDisplay; + } else { Logger::error(L"Can't convert string value={} to ESettingsWindowNames", winrt::to_hstring(value)); diff --git a/src/runner/settings_window.h b/src/runner/settings_window.h index 507d1c65b4..4da4d70a7a 100644 --- a/src/runner/settings_window.h +++ b/src/runner/settings_window.h @@ -36,6 +36,7 @@ enum class ESettingsWindowNames NewPlus, CmdPal, ZoomIt, + PowerDisplay, }; std::string ESettingsWindowNames_to_string(ESettingsWindowNames value); diff --git a/src/runner/trace.cpp b/src/runner/trace.cpp index b4682be8ce..6fb2f89ba8 100644 --- a/src/runner/trace.cpp +++ b/src/runner/trace.cpp @@ -81,3 +81,36 @@ void Trace::UpdateDownloadCompleted(bool success, const std::wstring& version) TraceLoggingBoolean(TRUE, "UTCReplace_AppSessionGuid"), TraceLoggingKeyword(PROJECT_KEYWORD_MEASURE)); } + +void Trace::TrayIconLeftClick(bool quickAccessEnabled) +{ + TraceLoggingWriteWrapper( + g_hProvider, + "TrayIcon_LeftClick", + TraceLoggingBoolean(quickAccessEnabled, "QuickAccessEnabled"), + ProjectTelemetryPrivacyDataTag(ProjectTelemetryTag_ProductAndServicePerformance), + TraceLoggingBoolean(TRUE, "UTCReplace_AppSessionGuid"), + TraceLoggingKeyword(PROJECT_KEYWORD_MEASURE)); +} + +void Trace::TrayIconDoubleClick(bool quickAccessEnabled) +{ + TraceLoggingWriteWrapper( + g_hProvider, + "TrayIcon_DoubleClick", + TraceLoggingBoolean(quickAccessEnabled, "QuickAccessEnabled"), + ProjectTelemetryPrivacyDataTag(ProjectTelemetryTag_ProductAndServicePerformance), + TraceLoggingBoolean(TRUE, "UTCReplace_AppSessionGuid"), + TraceLoggingKeyword(PROJECT_KEYWORD_MEASURE)); +} + +void Trace::TrayIconRightClick(bool quickAccessEnabled) +{ + TraceLoggingWriteWrapper( + g_hProvider, + "TrayIcon_RightClick", + TraceLoggingBoolean(quickAccessEnabled, "QuickAccessEnabled"), + ProjectTelemetryPrivacyDataTag(ProjectTelemetryTag_ProductAndServicePerformance), + TraceLoggingBoolean(TRUE, "UTCReplace_AppSessionGuid"), + TraceLoggingKeyword(PROJECT_KEYWORD_MEASURE)); +} diff --git a/src/runner/trace.h b/src/runner/trace.h index ca26aef66b..fb22ce3301 100644 --- a/src/runner/trace.h +++ b/src/runner/trace.h @@ -13,4 +13,9 @@ public: // Auto-update telemetry static void UpdateCheckCompleted(bool success, bool updateAvailable, const std::wstring& fromVersion, const std::wstring& toVersion); static void UpdateDownloadCompleted(bool success, const std::wstring& version); + + // Tray icon interaction telemetry + static void TrayIconLeftClick(bool quickAccessEnabled); + static void TrayIconDoubleClick(bool quickAccessEnabled); + static void TrayIconRightClick(bool quickAccessEnabled); }; diff --git a/src/runner/tray_icon.cpp b/src/runner/tray_icon.cpp index 8fa892e312..307129d63b 100644 --- a/src/runner/tray_icon.cpp +++ b/src/runner/tray_icon.cpp @@ -7,6 +7,7 @@ #include "centralized_kb_hook.h" #include "quick_access_host.h" #include "hotkey_conflict_detector.h" +#include "trace.h" #include #include @@ -40,6 +41,7 @@ namespace bool double_click_timer_running = false; bool double_clicked = false; POINT tray_icon_click_point; + std::optional last_quick_access_state; // Track the last known Quick Access state static ThemeListener theme_listener; static bool theme_adaptive_enabled = false; @@ -130,6 +132,9 @@ void click_timer_elapsed() double_click_timer_running = false; if (!double_clicked) { + // Log telemetry for single click (confirmed it's not a double click) + Trace::TrayIconLeftClick(get_general_settings().enableQuickAccess); + if (get_general_settings().enableQuickAccess) { open_quick_access_flyout_window(); @@ -195,6 +200,21 @@ LRESULT __stdcall tray_icon_window_proc(HWND window, UINT message, WPARAM wparam case WM_RBUTTONUP: case WM_CONTEXTMENU: { + bool quick_access_enabled = get_general_settings().enableQuickAccess; + + // Log telemetry + Trace::TrayIconRightClick(quick_access_enabled); + + // Reload menu if Quick Access state has changed or is first time + if (h_menu && (!last_quick_access_state.has_value() || quick_access_enabled != last_quick_access_state.value())) + { + DestroyMenu(h_menu); + h_menu = nullptr; + h_sub_menu = nullptr; + } + + last_quick_access_state = quick_access_enabled; + if (!h_menu) { h_menu = LoadMenu(reinterpret_cast(&__ImageBase), MAKEINTRESOURCE(ID_TRAY_MENU)); @@ -202,17 +222,39 @@ LRESULT __stdcall tray_icon_window_proc(HWND window, UINT message, WPARAM wparam if (h_menu) { static std::wstring settings_menuitem_label = GET_RESOURCE_STRING(IDS_SETTINGS_MENU_TEXT); + static std::wstring settings_menuitem_label_leftclick = GET_RESOURCE_STRING(IDS_SETTINGS_MENU_TEXT_LEFTCLICK); static std::wstring close_menuitem_label = GET_RESOURCE_STRING(IDS_CLOSE_MENU_TEXT); static std::wstring submit_bug_menuitem_label = GET_RESOURCE_STRING(IDS_SUBMIT_BUG_TEXT); static std::wstring documentation_menuitem_label = GET_RESOURCE_STRING(IDS_DOCUMENTATION_MENU_TEXT); static std::wstring quick_access_menuitem_label = GET_RESOURCE_STRING(IDS_QUICK_ACCESS_MENU_TEXT); - change_menu_item_text(ID_SETTINGS_MENU_COMMAND, settings_menuitem_label.data()); + + // Update Settings menu text based on Quick Access state + if (quick_access_enabled) + { + change_menu_item_text(ID_SETTINGS_MENU_COMMAND, settings_menuitem_label.data()); + } + else + { + change_menu_item_text(ID_SETTINGS_MENU_COMMAND, settings_menuitem_label_leftclick.data()); + } + change_menu_item_text(ID_CLOSE_MENU_COMMAND, close_menuitem_label.data()); change_menu_item_text(ID_REPORT_BUG_COMMAND, submit_bug_menuitem_label.data()); bool bug_report_disabled = is_bug_report_running(); EnableMenuItem(h_sub_menu, ID_REPORT_BUG_COMMAND, MF_BYCOMMAND | (bug_report_disabled ? MF_GRAYED : MF_ENABLED)); change_menu_item_text(ID_DOCUMENTATION_MENU_COMMAND, documentation_menuitem_label.data()); change_menu_item_text(ID_QUICK_ACCESS_MENU_COMMAND, quick_access_menuitem_label.data()); + + // Hide or show Quick Access menu item based on setting + if (!h_sub_menu) + { + h_sub_menu = GetSubMenu(h_menu, 0); + } + if (!quick_access_enabled) + { + // Remove Quick Access menu item when disabled + DeleteMenu(h_sub_menu, ID_QUICK_ACCESS_MENU_COMMAND, MF_BYCOMMAND); + } } if (!h_sub_menu) { @@ -243,6 +285,9 @@ LRESULT __stdcall tray_icon_window_proc(HWND window, UINT message, WPARAM wparam } case WM_LBUTTONDBLCLK: { + // Log telemetry + Trace::TrayIconDoubleClick(get_general_settings().enableQuickAccess); + double_clicked = true; open_settings_window(std::nullopt); break; diff --git a/src/settings-ui/Settings.UI.Controls/QuickAccess/QuickAccessLauncher.cs b/src/settings-ui/Settings.UI.Controls/QuickAccess/QuickAccessLauncher.cs index b81c9638f3..1347ce86c1 100644 --- a/src/settings-ui/Settings.UI.Controls/QuickAccess/QuickAccessLauncher.cs +++ b/src/settings-ui/Settings.UI.Controls/QuickAccess/QuickAccessLauncher.cs @@ -119,6 +119,13 @@ namespace Microsoft.PowerToys.Settings.UI.Controls eventHandle.Set(); } + return true; + case ModuleType.PowerDisplay: + using (var eventHandle = new EventWaitHandle(false, EventResetMode.AutoReset, Constants.TogglePowerDisplayEvent())) + { + eventHandle.Set(); + } + return true; default: return false; diff --git a/src/settings-ui/Settings.UI.Controls/QuickAccess/QuickAccessViewModel.cs b/src/settings-ui/Settings.UI.Controls/QuickAccess/QuickAccessViewModel.cs index 50fc46af09..2fb626869d 100644 --- a/src/settings-ui/Settings.UI.Controls/QuickAccess/QuickAccessViewModel.cs +++ b/src/settings-ui/Settings.UI.Controls/QuickAccess/QuickAccessViewModel.cs @@ -65,6 +65,7 @@ namespace Microsoft.PowerToys.Settings.UI.Controls AddFlyoutMenuItem(ModuleType.FancyZones); AddFlyoutMenuItem(ModuleType.Hosts); AddFlyoutMenuItem(ModuleType.LightSwitch); + AddFlyoutMenuItem(ModuleType.PowerDisplay); AddFlyoutMenuItem(ModuleType.PowerLauncher); AddFlyoutMenuItem(ModuleType.PowerOCR); AddFlyoutMenuItem(ModuleType.RegistryPreview); @@ -121,6 +122,7 @@ namespace Microsoft.PowerToys.Settings.UI.Controls { ModuleType.ColorPicker => SettingsRepository.GetInstance(SettingsUtils.Default).SettingsConfig.Properties.ActivationShortcut.ToString(), ModuleType.FancyZones => SettingsRepository.GetInstance(SettingsUtils.Default).SettingsConfig.Properties.FancyzonesEditorHotkey.Value.ToString(), + ModuleType.PowerDisplay => SettingsRepository.GetInstance(SettingsUtils.Default).SettingsConfig.Properties.ActivationShortcut.ToString(), ModuleType.LightSwitch => SettingsRepository.GetInstance(SettingsUtils.Default).SettingsConfig.Properties.ToggleThemeHotkey.Value.ToString(), ModuleType.PowerLauncher => SettingsRepository.GetInstance(SettingsUtils.Default).SettingsConfig.Properties.OpenPowerLauncher.ToString(), ModuleType.PowerOCR => SettingsRepository.GetInstance(SettingsUtils.Default).SettingsConfig.Properties.ActivationShortcut.ToString(), diff --git a/src/settings-ui/Settings.UI.Library/AdvancedPasteAdditionalActions.cs b/src/settings-ui/Settings.UI.Library/AdvancedPasteAdditionalActions.cs index 6d908c617a..b193c01c74 100644 --- a/src/settings-ui/Settings.UI.Library/AdvancedPasteAdditionalActions.cs +++ b/src/settings-ui/Settings.UI.Library/AdvancedPasteAdditionalActions.cs @@ -10,6 +10,10 @@ namespace Microsoft.PowerToys.Settings.UI.Library; public sealed class AdvancedPasteAdditionalActions { + private AdvancedPasteAdditionalAction _imageToText = new(); + private AdvancedPastePasteAsFileAction _pasteAsFile = new(); + private AdvancedPasteTranscodeAction _transcode = new(); + public static class PropertyNames { public const string ImageToText = "image-to-text"; @@ -18,13 +22,25 @@ public sealed class AdvancedPasteAdditionalActions } [JsonPropertyName(PropertyNames.ImageToText)] - public AdvancedPasteAdditionalAction ImageToText { get; init; } = new(); + public AdvancedPasteAdditionalAction ImageToText + { + get => _imageToText; + init => _imageToText = value ?? new(); + } [JsonPropertyName(PropertyNames.PasteAsFile)] - public AdvancedPastePasteAsFileAction PasteAsFile { get; init; } = new(); + public AdvancedPastePasteAsFileAction PasteAsFile + { + get => _pasteAsFile; + init => _pasteAsFile = value ?? new(); + } [JsonPropertyName(PropertyNames.Transcode)] - public AdvancedPasteTranscodeAction Transcode { get; init; } = new(); + public AdvancedPasteTranscodeAction Transcode + { + get => _transcode; + init => _transcode = value ?? new(); + } public IEnumerable GetAllActions() { diff --git a/src/settings-ui/Settings.UI.Library/AdvancedPastePasteAsFileAction.cs b/src/settings-ui/Settings.UI.Library/AdvancedPastePasteAsFileAction.cs index c4489eaaf7..b645c68cb5 100644 --- a/src/settings-ui/Settings.UI.Library/AdvancedPastePasteAsFileAction.cs +++ b/src/settings-ui/Settings.UI.Library/AdvancedPastePasteAsFileAction.cs @@ -34,21 +34,21 @@ public sealed class AdvancedPastePasteAsFileAction : Observable, IAdvancedPasteA public AdvancedPasteAdditionalAction PasteAsTxtFile { get => _pasteAsTxtFile; - init => Set(ref _pasteAsTxtFile, value); + init => Set(ref _pasteAsTxtFile, value ?? new()); } [JsonPropertyName(PropertyNames.PasteAsPngFile)] public AdvancedPasteAdditionalAction PasteAsPngFile { get => _pasteAsPngFile; - init => Set(ref _pasteAsPngFile, value); + init => Set(ref _pasteAsPngFile, value ?? new()); } [JsonPropertyName(PropertyNames.PasteAsHtmlFile)] public AdvancedPasteAdditionalAction PasteAsHtmlFile { get => _pasteAsHtmlFile; - init => Set(ref _pasteAsHtmlFile, value); + init => Set(ref _pasteAsHtmlFile, value ?? new()); } [JsonIgnore] diff --git a/src/settings-ui/Settings.UI.Library/AdvancedPasteProperties.cs b/src/settings-ui/Settings.UI.Library/AdvancedPasteProperties.cs index 9e2fa7ee12..ecfa0ce636 100644 --- a/src/settings-ui/Settings.UI.Library/AdvancedPasteProperties.cs +++ b/src/settings-ui/Settings.UI.Library/AdvancedPasteProperties.cs @@ -93,11 +93,11 @@ namespace Microsoft.PowerToys.Settings.UI.Library [JsonPropertyName("custom-actions")] [CmdConfigureIgnoreAttribute] - public AdvancedPasteCustomActions CustomActions { get; init; } + public AdvancedPasteCustomActions CustomActions { get; set; } [JsonPropertyName("additional-actions")] [CmdConfigureIgnoreAttribute] - public AdvancedPasteAdditionalActions AdditionalActions { get; init; } + public AdvancedPasteAdditionalActions AdditionalActions { get; set; } [JsonPropertyName("paste-ai-configuration")] [CmdConfigureIgnoreAttribute] diff --git a/src/settings-ui/Settings.UI.Library/AdvancedPasteTranscodeAction.cs b/src/settings-ui/Settings.UI.Library/AdvancedPasteTranscodeAction.cs index 82ea4d09f5..e0ed7d7421 100644 --- a/src/settings-ui/Settings.UI.Library/AdvancedPasteTranscodeAction.cs +++ b/src/settings-ui/Settings.UI.Library/AdvancedPasteTranscodeAction.cs @@ -32,14 +32,14 @@ public sealed class AdvancedPasteTranscodeAction : Observable, IAdvancedPasteAct public AdvancedPasteAdditionalAction TranscodeToMp3 { get => _transcodeToMp3; - init => Set(ref _transcodeToMp3, value); + init => Set(ref _transcodeToMp3, value ?? new()); } [JsonPropertyName(PropertyNames.TranscodeToMp4)] public AdvancedPasteAdditionalAction TranscodeToMp4 { get => _transcodeToMp4; - init => Set(ref _transcodeToMp4, value); + init => Set(ref _transcodeToMp4, value ?? new()); } [JsonIgnore] diff --git a/src/settings-ui/Settings.UI.Library/CursorWrapProperties.cs b/src/settings-ui/Settings.UI.Library/CursorWrapProperties.cs index cf66b4ba09..228cf74998 100644 --- a/src/settings-ui/Settings.UI.Library/CursorWrapProperties.cs +++ b/src/settings-ui/Settings.UI.Library/CursorWrapProperties.cs @@ -22,11 +22,19 @@ namespace Microsoft.PowerToys.Settings.UI.Library [JsonPropertyName("disable_wrap_during_drag")] public BoolProperty DisableWrapDuringDrag { get; set; } + [JsonPropertyName("wrap_mode")] + public IntProperty WrapMode { get; set; } + + [JsonPropertyName("disable_cursor_wrap_on_single_monitor")] + public BoolProperty DisableCursorWrapOnSingleMonitor { get; set; } + public CursorWrapProperties() { ActivationShortcut = DefaultActivationShortcut; AutoActivate = new BoolProperty(false); DisableWrapDuringDrag = new BoolProperty(true); + WrapMode = new IntProperty(0); // 0=Both (default), 1=VerticalOnly, 2=HorizontalOnly + DisableCursorWrapOnSingleMonitor = new BoolProperty(false); } } } diff --git a/src/settings-ui/Settings.UI.Library/CursorWrapSettings.cs b/src/settings-ui/Settings.UI.Library/CursorWrapSettings.cs index 8c9059123c..fc918c37db 100644 --- a/src/settings-ui/Settings.UI.Library/CursorWrapSettings.cs +++ b/src/settings-ui/Settings.UI.Library/CursorWrapSettings.cs @@ -47,7 +47,23 @@ namespace Microsoft.PowerToys.Settings.UI.Library // This can be utilized in the future if the settings.json file is to be modified/deleted. public bool UpgradeSettingsConfiguration() { - return false; + bool settingsUpgraded = false; + + // Add WrapMode property if it doesn't exist (for users upgrading from older versions) + if (Properties.WrapMode == null) + { + Properties.WrapMode = new IntProperty(0); // Default to Both + settingsUpgraded = true; + } + + // Add DisableCursorWrapOnSingleMonitor property if it doesn't exist (for users upgrading from older versions) + if (Properties.DisableCursorWrapOnSingleMonitor == null) + { + Properties.DisableCursorWrapOnSingleMonitor = new BoolProperty(false); // Default to false + settingsUpgraded = true; + } + + return settingsUpgraded; } } } diff --git a/src/settings-ui/Settings.UI.Library/EnabledModules.cs b/src/settings-ui/Settings.UI.Library/EnabledModules.cs index d7100d9ae4..f56176a1f0 100644 --- a/src/settings-ui/Settings.UI.Library/EnabledModules.cs +++ b/src/settings-ui/Settings.UI.Library/EnabledModules.cs @@ -546,6 +546,23 @@ namespace Microsoft.PowerToys.Settings.UI.Library } } + private bool powerDisplay; + + [JsonPropertyName("PowerDisplay")] + public bool PowerDisplay + { + get => powerDisplay; + set + { + if (powerDisplay != value) + { + LogTelemetryEvent(value); + powerDisplay = value; + NotifyChange(); + } + } + } + private void NotifyChange() { notifyEnabledChangedAction?.Invoke(); diff --git a/src/settings-ui/Settings.UI.Library/Helpers/ModuleHelper.cs b/src/settings-ui/Settings.UI.Library/Helpers/ModuleHelper.cs index f96eac8ce8..9b4581957c 100644 --- a/src/settings-ui/Settings.UI.Library/Helpers/ModuleHelper.cs +++ b/src/settings-ui/Settings.UI.Library/Helpers/ModuleHelper.cs @@ -73,6 +73,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library.Helpers ModuleType.MeasureTool => generalSettingsConfig.Enabled.MeasureTool, ModuleType.ShortcutGuide => generalSettingsConfig.Enabled.ShortcutGuide, ModuleType.PowerOCR => generalSettingsConfig.Enabled.PowerOcr, + ModuleType.PowerDisplay => generalSettingsConfig.Enabled.PowerDisplay, ModuleType.Workspaces => generalSettingsConfig.Enabled.Workspaces, ModuleType.ZoomIt => generalSettingsConfig.Enabled.ZoomIt, ModuleType.GeneralSettings => generalSettingsConfig.EnableQuickAccess, @@ -112,6 +113,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library.Helpers case ModuleType.MeasureTool: generalSettingsConfig.Enabled.MeasureTool = isEnabled; break; case ModuleType.ShortcutGuide: generalSettingsConfig.Enabled.ShortcutGuide = isEnabled; break; case ModuleType.PowerOCR: generalSettingsConfig.Enabled.PowerOcr = isEnabled; break; + case ModuleType.PowerDisplay: generalSettingsConfig.Enabled.PowerDisplay = isEnabled; break; case ModuleType.Workspaces: generalSettingsConfig.Enabled.Workspaces = isEnabled; break; case ModuleType.ZoomIt: generalSettingsConfig.Enabled.ZoomIt = isEnabled; break; case ModuleType.GeneralSettings: generalSettingsConfig.EnableQuickAccess = isEnabled; break; diff --git a/src/settings-ui/Settings.UI.Library/LightSwitchProperties.cs b/src/settings-ui/Settings.UI.Library/LightSwitchProperties.cs index 8f5bf88a19..4c56051ce9 100644 --- a/src/settings-ui/Settings.UI.Library/LightSwitchProperties.cs +++ b/src/settings-ui/Settings.UI.Library/LightSwitchProperties.cs @@ -17,6 +17,10 @@ namespace Microsoft.PowerToys.Settings.UI.Library public const string DefaultLatitude = "0.0"; public const string DefaultLongitude = "0.0"; public const string DefaultScheduleMode = "Off"; + public const bool DefaultEnableDarkModeProfile = false; + public const bool DefaultEnableLightModeProfile = false; + public const string DefaultDarkModeProfile = ""; + public const string DefaultLightModeProfile = ""; public static readonly HotkeySettings DefaultToggleThemeHotkey = new HotkeySettings(true, true, false, true, 0x44); // Ctrl+Win+Shift+D public LightSwitchProperties() @@ -31,6 +35,10 @@ namespace Microsoft.PowerToys.Settings.UI.Library SunsetOffset = new IntProperty(DefaultSunsetOffset); ScheduleMode = new StringProperty(DefaultScheduleMode); ToggleThemeHotkey = new KeyboardKeysProperty(DefaultToggleThemeHotkey); + EnableDarkModeProfile = new BoolProperty(DefaultEnableDarkModeProfile); + EnableLightModeProfile = new BoolProperty(DefaultEnableLightModeProfile); + DarkModeProfile = new StringProperty(DefaultDarkModeProfile); + LightModeProfile = new StringProperty(DefaultLightModeProfile); } [JsonPropertyName("changeSystem")] @@ -62,5 +70,17 @@ namespace Microsoft.PowerToys.Settings.UI.Library [JsonPropertyName("toggle-theme-hotkey")] public KeyboardKeysProperty ToggleThemeHotkey { get; set; } + + [JsonPropertyName("enableDarkModeProfile")] + public BoolProperty EnableDarkModeProfile { get; set; } + + [JsonPropertyName("enableLightModeProfile")] + public BoolProperty EnableLightModeProfile { get; set; } + + [JsonPropertyName("darkModeProfile")] + public StringProperty DarkModeProfile { get; set; } + + [JsonPropertyName("lightModeProfile")] + public StringProperty LightModeProfile { get; set; } } } diff --git a/src/settings-ui/Settings.UI.Library/LightSwitchSettings.cs b/src/settings-ui/Settings.UI.Library/LightSwitchSettings.cs index ce76ec72bf..4aa5647102 100644 --- a/src/settings-ui/Settings.UI.Library/LightSwitchSettings.cs +++ b/src/settings-ui/Settings.UI.Library/LightSwitchSettings.cs @@ -60,6 +60,10 @@ namespace Microsoft.PowerToys.Settings.UI.Library Latitude = new StringProperty(Properties.Latitude.Value), Longitude = new StringProperty(Properties.Longitude.Value), ToggleThemeHotkey = new KeyboardKeysProperty(Properties.ToggleThemeHotkey.Value), + EnableDarkModeProfile = new BoolProperty(Properties.EnableDarkModeProfile.Value), + EnableLightModeProfile = new BoolProperty(Properties.EnableLightModeProfile.Value), + DarkModeProfile = new StringProperty(Properties.DarkModeProfile.Value), + LightModeProfile = new StringProperty(Properties.LightModeProfile.Value), }, }; } diff --git a/src/settings-ui/Settings.UI.Library/MonitorInfo.cs b/src/settings-ui/Settings.UI.Library/MonitorInfo.cs new file mode 100644 index 0000000000..f53f682d3d --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/MonitorInfo.cs @@ -0,0 +1,694 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Text.Json.Serialization; +using Microsoft.PowerToys.Settings.UI.Library.Helpers; +using PowerDisplay.Common.Drivers; +using PowerDisplay.Common.Models; +using PowerDisplay.Common.Utils; + +namespace Microsoft.PowerToys.Settings.UI.Library +{ + public class MonitorInfo : Observable + { + private string _name = string.Empty; + private string _id = string.Empty; + private string _communicationMethod = string.Empty; + private int _currentBrightness; + private int _colorTemperatureVcp = 0x05; // Default to 6500K preset (VCP 0x14 value) + private int _contrast; + private int _volume; + private bool _isHidden; + private bool _enableContrast; + private bool _enableVolume; + private bool _enableInputSource; + private bool _enableRotation; + private bool _enableColorTemperature; + private bool _enablePowerState; + private string _capabilitiesRaw = string.Empty; + private List _vcpCodesFormatted = new List(); + private int _monitorNumber; + private int _totalMonitorCount; + + // Feature support status (determined from capabilities) + private bool _supportsBrightness = true; // Brightness always shown even if unsupported + private bool _supportsContrast; + private bool _supportsColorTemperature; + private bool _supportsVolume; + private bool _supportsInputSource; + private bool _supportsPowerState; + + // Cached color temperature presets (computed from VcpCodesFormatted) + private ObservableCollection _availableColorPresetsCache; + private ObservableCollection _colorPresetsForDisplayCache; + private int _lastColorTemperatureVcpForCache = -1; + + /// + /// Invalidates the color preset cache and notifies property changes. + /// Call this when VcpCodesFormatted or SupportsColorTemperature changes. + /// + private void InvalidateColorPresetCache() + { + _availableColorPresetsCache = null; + _colorPresetsForDisplayCache = null; + _lastColorTemperatureVcpForCache = -1; + OnPropertyChanged(nameof(ColorPresetsForDisplay)); + } + + public MonitorInfo() + { + } + + [JsonPropertyName("name")] + public string Name + { + get => _name; + set + { + if (_name != value) + { + _name = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(DisplayName)); + } + } + } + + /// + /// Gets or sets the monitor number (Windows DISPLAY number, e.g., 1, 2, 3...). + /// + [JsonPropertyName("monitorNumber")] + public int MonitorNumber + { + get => _monitorNumber; + set + { + if (_monitorNumber != value) + { + _monitorNumber = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(DisplayName)); + } + } + } + + /// + /// Gets or sets the total number of monitors (used for dynamic display name). + /// This is not serialized; it's set by the ViewModel. + /// + [JsonIgnore] + public int TotalMonitorCount + { + get => _totalMonitorCount; + set + { + if (_totalMonitorCount != value) + { + _totalMonitorCount = value; + OnPropertyChanged(nameof(DisplayName)); + } + } + } + + /// + /// Gets the display name - includes monitor number when multiple monitors exist. + /// Follows the same logic as PowerDisplay UI's MonitorViewModel.DisplayName. + /// + [JsonIgnore] + public string DisplayName + { + get + { + // Show monitor number only when there are multiple monitors and MonitorNumber is valid + if (TotalMonitorCount > 1 && MonitorNumber > 0) + { + return $"{Name} {MonitorNumber}"; + } + + return Name; + } + } + + public string MonitorIconGlyph => CommunicationMethod.Contains("WMI", StringComparison.OrdinalIgnoreCase) + ? "\uE7F8" // Laptop icon for WMI + : "\uE7F4"; // External monitor icon for DDC/CI and others + + [JsonPropertyName("id")] + public string Id + { + get => _id; + set + { + if (_id != value) + { + _id = value; + OnPropertyChanged(); + } + } + } + + [JsonPropertyName("communicationMethod")] + public string CommunicationMethod + { + get => _communicationMethod; + set + { + if (_communicationMethod != value) + { + _communicationMethod = value; + OnPropertyChanged(); + } + } + } + + [JsonPropertyName("currentBrightness")] + public int CurrentBrightness + { + get => _currentBrightness; + set + { + if (_currentBrightness != value) + { + _currentBrightness = value; + OnPropertyChanged(); + } + } + } + + /// + /// Gets or sets the color temperature VCP preset value (raw DDC/CI value from VCP code 0x14). + /// This stores the raw VCP value (e.g., 0x05 for 6500K preset), not the Kelvin temperature. + /// + [JsonPropertyName("colorTemperatureVcp")] + public int ColorTemperatureVcp + { + get => _colorTemperatureVcp; + set + { + if (_colorTemperatureVcp != value) + { + _colorTemperatureVcp = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(ColorPresetsForDisplay)); // Update display list when current value changes + } + } + } + + /// + /// Gets or sets the current contrast value (0-100). + /// + [JsonPropertyName("contrast")] + public int Contrast + { + get => _contrast; + set + { + if (_contrast != value) + { + _contrast = value; + OnPropertyChanged(); + } + } + } + + /// + /// Gets or sets the current volume value (0-100). + /// + [JsonPropertyName("volume")] + public int Volume + { + get => _volume; + set + { + if (_volume != value) + { + _volume = value; + OnPropertyChanged(); + } + } + } + + [JsonPropertyName("isHidden")] + public bool IsHidden + { + get => _isHidden; + set + { + if (_isHidden != value) + { + _isHidden = value; + OnPropertyChanged(); + } + } + } + + [JsonPropertyName("enableContrast")] + public bool EnableContrast + { + get => _enableContrast; + set + { + if (_enableContrast != value) + { + _enableContrast = value; + OnPropertyChanged(); + } + } + } + + [JsonPropertyName("enableVolume")] + public bool EnableVolume + { + get => _enableVolume; + set + { + if (_enableVolume != value) + { + _enableVolume = value; + OnPropertyChanged(); + } + } + } + + [JsonPropertyName("enableInputSource")] + public bool EnableInputSource + { + get => _enableInputSource; + set + { + if (_enableInputSource != value) + { + _enableInputSource = value; + OnPropertyChanged(); + } + } + } + + [JsonPropertyName("enableRotation")] + public bool EnableRotation + { + get => _enableRotation; + set + { + if (_enableRotation != value) + { + _enableRotation = value; + OnPropertyChanged(); + } + } + } + + [JsonPropertyName("enableColorTemperature")] + public bool EnableColorTemperature + { + get => _enableColorTemperature; + set + { + if (_enableColorTemperature != value) + { + _enableColorTemperature = value; + OnPropertyChanged(); + } + } + } + + [JsonPropertyName("enablePowerState")] + public bool EnablePowerState + { + get => _enablePowerState; + set + { + if (_enablePowerState != value) + { + _enablePowerState = value; + OnPropertyChanged(); + } + } + } + + [JsonPropertyName("capabilitiesRaw")] + public string CapabilitiesRaw + { + get => _capabilitiesRaw; + set + { + if (_capabilitiesRaw != value) + { + _capabilitiesRaw = value ?? string.Empty; + OnPropertyChanged(); + OnPropertyChanged(nameof(HasCapabilities)); + } + } + } + + [JsonPropertyName("vcpCodesFormatted")] + public List VcpCodesFormatted + { + get => _vcpCodesFormatted; + set + { + var newValue = value ?? new List(); + + // Only update if content actually changed (compare by VCP code list content) + if (AreVcpCodesEqual(_vcpCodesFormatted, newValue)) + { + return; + } + + _vcpCodesFormatted = newValue; + OnPropertyChanged(); + InvalidateColorPresetCache(); + } + } + + /// + /// Compare two VcpCodesFormatted lists for equality by content. + /// Returns true if both lists have the same VCP codes (by code value). + /// + private static bool AreVcpCodesEqual(List list1, List list2) + { + if (list1 == null && list2 == null) + { + return true; + } + + if (list1 == null || list2 == null) + { + return false; + } + + if (list1.Count != list2.Count) + { + return false; + } + + // Compare by code values - order matters for our use case + for (int i = 0; i < list1.Count; i++) + { + if (list1[i].Code != list2[i].Code) + { + return false; + } + + // Also compare ValueList count to detect preset changes + var values1 = list1[i].ValueList; + var values2 = list2[i].ValueList; + if ((values1?.Count ?? 0) != (values2?.Count ?? 0)) + { + return false; + } + } + + return true; + } + + [JsonPropertyName("supportsBrightness")] + public bool SupportsBrightness + { + get => _supportsBrightness; + set + { + if (_supportsBrightness != value) + { + _supportsBrightness = value; + OnPropertyChanged(); + } + } + } + + [JsonPropertyName("supportsContrast")] + public bool SupportsContrast + { + get => _supportsContrast; + set + { + if (_supportsContrast != value) + { + _supportsContrast = value; + OnPropertyChanged(); + } + } + } + + [JsonPropertyName("supportsColorTemperature")] + public bool SupportsColorTemperature + { + get => _supportsColorTemperature; + set + { + if (_supportsColorTemperature != value) + { + _supportsColorTemperature = value; + OnPropertyChanged(); + InvalidateColorPresetCache(); // Notifies ColorPresetsForDisplay + } + } + } + + [JsonPropertyName("supportsVolume")] + public bool SupportsVolume + { + get => _supportsVolume; + set + { + if (_supportsVolume != value) + { + _supportsVolume = value; + OnPropertyChanged(); + } + } + } + + [JsonPropertyName("supportsInputSource")] + public bool SupportsInputSource + { + get => _supportsInputSource; + set + { + if (_supportsInputSource != value) + { + _supportsInputSource = value; + OnPropertyChanged(); + } + } + } + + [JsonPropertyName("supportsPowerState")] + public bool SupportsPowerState + { + get => _supportsPowerState; + set + { + if (_supportsPowerState != value) + { + _supportsPowerState = value; + OnPropertyChanged(); + } + } + } + + /// + /// Gets available color temperature presets computed from VcpCodesFormatted (VCP code 0x14). + /// This is a computed property that parses the VCP capabilities data on-demand. + /// + private ObservableCollection AvailableColorPresets + { + get + { + // Return cached value if available + if (_availableColorPresetsCache != null) + { + return _availableColorPresetsCache; + } + + // Compute from VcpCodesFormatted + _availableColorPresetsCache = ComputeAvailableColorPresets(); + return _availableColorPresetsCache; + } + } + + /// + /// Compute available color presets from VcpCodesFormatted (VCP code 0x14). + /// Uses ColorTemperatureHelper from PowerDisplay.Lib for shared computation logic. + /// + private ObservableCollection ComputeAvailableColorPresets() + { + // Check if color temperature is supported + if (!_supportsColorTemperature || _vcpCodesFormatted == null) + { + return new ObservableCollection(); + } + + // Find VCP code 0x14 (Color Temperature / Select Color Preset) + var colorTempVcp = _vcpCodesFormatted.FirstOrDefault(v => + !string.IsNullOrEmpty(v.Code) && + int.TryParse( + v.Code.StartsWith("0x", StringComparison.OrdinalIgnoreCase) ? v.Code[2..] : v.Code, + System.Globalization.NumberStyles.HexNumber, + System.Globalization.CultureInfo.InvariantCulture, + out int code) && + code == NativeConstants.VcpCodeSelectColorPreset); + + // No VCP 0x14 or no values + if (colorTempVcp == null || colorTempVcp.ValueList == null || colorTempVcp.ValueList.Count == 0) + { + return new ObservableCollection(); + } + + // Extract VCP values as tuples for ColorTemperatureHelper + var colorTempValues = colorTempVcp.ValueList + .Select(valueInfo => + { + var hex = valueInfo.Value; + if (string.IsNullOrEmpty(hex)) + { + return (VcpValue: 0, Name: valueInfo.Name); + } + + var cleanHex = hex.StartsWith("0x", StringComparison.OrdinalIgnoreCase) ? hex[2..] : hex; + bool parsed = int.TryParse(cleanHex, System.Globalization.NumberStyles.HexNumber, System.Globalization.CultureInfo.InvariantCulture, out int vcpValue); + return (VcpValue: parsed ? vcpValue : 0, Name: valueInfo.Name); + }) + .Where(x => x.VcpValue > 0); + + // Use shared helper to compute presets, then convert to nested type for XAML compatibility + var basePresets = ColorTemperatureHelper.ComputeColorPresets(colorTempValues); + var presetList = basePresets.Select(p => new ColorPresetItem(p.VcpValue, p.DisplayName)); + return new ObservableCollection(presetList); + } + + /// + /// Gets color presets for display in ComboBox, includes current value if not in preset list. + /// Uses caching to avoid recreating collections on every access. + /// + [JsonIgnore] + public ObservableCollection ColorPresetsForDisplay + { + get + { + // Return cached value if available and color temperature hasn't changed + if (_colorPresetsForDisplayCache != null && _lastColorTemperatureVcpForCache == _colorTemperatureVcp) + { + return _colorPresetsForDisplayCache; + } + + var presets = AvailableColorPresets; + if (presets == null || presets.Count == 0) + { + _colorPresetsForDisplayCache = new ObservableCollection(); + _lastColorTemperatureVcpForCache = _colorTemperatureVcp; + return _colorPresetsForDisplayCache; + } + + // Check if current value is in the preset list + var currentValueInList = presets.Any(p => p.VcpValue == _colorTemperatureVcp); + + if (currentValueInList) + { + // Current value is in the list, return as-is + _colorPresetsForDisplayCache = presets; + } + else + { + // Current value is not in the preset list - add it at the beginning + var displayList = new List(); + + // Add current value with "Custom" indicator using shared helper + var displayName = ColorTemperatureHelper.FormatCustomColorTemperatureDisplayName(_colorTemperatureVcp); + displayList.Add(new ColorPresetItem(_colorTemperatureVcp, displayName)); + + // Add all supported presets + displayList.AddRange(presets); + + _colorPresetsForDisplayCache = new ObservableCollection(displayList); + } + + _lastColorTemperatureVcpForCache = _colorTemperatureVcp; + return _colorPresetsForDisplayCache; + } + } + + [JsonIgnore] + public bool HasCapabilities => !string.IsNullOrEmpty(_capabilitiesRaw); + + [JsonIgnore] + public bool ShowCapabilitiesWarning => _communicationMethod.Contains("WMI", StringComparison.OrdinalIgnoreCase); + + /// + /// Generate formatted text of all VCP codes for clipboard + /// + public string GetVcpCodesAsText() + { + if (_vcpCodesFormatted == null || _vcpCodesFormatted.Count == 0) + { + return "No VCP codes detected"; + } + + var lines = new List(); + lines.Add($"VCP Capabilities for: {_name}"); + lines.Add($"Monitor ID: {_id}"); + lines.Add(string.Empty); + lines.Add("Detected VCP Codes:"); + lines.Add(new string('-', 50)); + + foreach (var vcp in _vcpCodesFormatted) + { + lines.Add(string.Empty); + lines.Add(vcp.Title); + if (vcp.HasValues) + { + lines.Add($" {vcp.Values}"); + } + } + + lines.Add(string.Empty); + lines.Add(new string('-', 50)); + lines.Add($"Total: {_vcpCodesFormatted.Count} VCP codes"); + + return string.Join(System.Environment.NewLine, lines); + } + + /// + /// Update this monitor's properties from another MonitorInfo instance. + /// This preserves the object reference while updating all properties. + /// + /// The source MonitorInfo to copy properties from + public void UpdateFrom(MonitorInfo other) + { + if (other == null) + { + return; + } + + // Update all properties that can change + Name = other.Name; + Id = other.Id; + CommunicationMethod = other.CommunicationMethod; + CurrentBrightness = other.CurrentBrightness; + Contrast = other.Contrast; + Volume = other.Volume; + ColorTemperatureVcp = other.ColorTemperatureVcp; + IsHidden = other.IsHidden; + EnableContrast = other.EnableContrast; + EnableVolume = other.EnableVolume; + EnableInputSource = other.EnableInputSource; + EnableRotation = other.EnableRotation; + EnableColorTemperature = other.EnableColorTemperature; + EnablePowerState = other.EnablePowerState; + CapabilitiesRaw = other.CapabilitiesRaw; + VcpCodesFormatted = other.VcpCodesFormatted; + SupportsBrightness = other.SupportsBrightness; + SupportsContrast = other.SupportsContrast; + SupportsColorTemperature = other.SupportsColorTemperature; + SupportsVolume = other.SupportsVolume; + SupportsInputSource = other.SupportsInputSource; + SupportsPowerState = other.SupportsPowerState; + MonitorNumber = other.MonitorNumber; + } + } +} diff --git a/src/settings-ui/Settings.UI.Library/PowerDisplayActionMessage.cs b/src/settings-ui/Settings.UI.Library/PowerDisplayActionMessage.cs new file mode 100644 index 0000000000..06ca2bf68f --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/PowerDisplayActionMessage.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Text.Json.Serialization; + +namespace Microsoft.PowerToys.Settings.UI.Library +{ + /// + /// Message for PowerDisplay module actions + /// + public class PowerDisplayActionMessage + { + [JsonPropertyName("action")] + public ActionData Action { get; set; } + + public class ActionData + { + [JsonPropertyName("PowerDisplay")] + public PowerDisplayAction PowerDisplay { get; set; } + } + + public class PowerDisplayAction + { + [JsonPropertyName("action_name")] + public string ActionName { get; set; } + + [JsonPropertyName("value")] + public string Value { get; set; } + } + } +} diff --git a/src/settings-ui/Settings.UI.Library/PowerDisplayProperties.cs b/src/settings-ui/Settings.UI.Library/PowerDisplayProperties.cs new file mode 100644 index 0000000000..5539daf0fd --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/PowerDisplayProperties.cs @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using System.Text.Json.Serialization; +using PowerDisplay.Common.Models; +using Settings.UI.Library.Attributes; + +namespace Microsoft.PowerToys.Settings.UI.Library +{ + public class PowerDisplayProperties + { + [CmdConfigureIgnore] + public HotkeySettings DefaultActivationShortcut => new HotkeySettings(true, true, false, true, 0x44); // Ctrl+Shift+Win+D (win, ctrl, alt, shift, code) + + public PowerDisplayProperties() + { + ActivationShortcut = DefaultActivationShortcut; + MonitorRefreshDelay = 5; + Monitors = new List(); + RestoreSettingsOnStartup = false; + ShowSystemTrayIcon = true; + ShowProfileSwitcher = true; + ShowIdentifyMonitorsButton = true; + CustomVcpMappings = new List(); + + // Note: saved_monitor_settings has been moved to monitor_state.json + // which is managed separately by PowerDisplay app + } + + [JsonPropertyName("activation_shortcut")] + public HotkeySettings ActivationShortcut { get; set; } + + /// + /// Gets or sets delay in seconds before refreshing monitors after display changes (hot-plug). + /// This allows hardware to stabilize before querying DDC/CI. + /// + [JsonPropertyName("monitor_refresh_delay")] + public int MonitorRefreshDelay { get; set; } + + [JsonPropertyName("monitors")] + public List Monitors { get; set; } + + [JsonPropertyName("restore_settings_on_startup")] + public bool RestoreSettingsOnStartup { get; set; } + + [JsonPropertyName("show_system_tray_icon")] + public bool ShowSystemTrayIcon { get; set; } + + /// + /// Gets or sets whether to show the profile switcher button in the flyout UI. + /// Default is true. When false, the profile switcher is hidden (but profiles still work via Settings). + /// Note: Also hidden when no profiles exist. + /// + [JsonPropertyName("show_profile_switcher")] + public bool ShowProfileSwitcher { get; set; } + + /// + /// Gets or sets whether to show the identify monitors button in the flyout UI. + /// Default is true. + /// + [JsonPropertyName("show_identify_monitors_button")] + public bool ShowIdentifyMonitorsButton { get; set; } + + /// + /// Gets or sets custom VCP value name mappings shared across all monitors. + /// Allows users to define custom names for color temperature presets and input sources. + /// + [JsonPropertyName("custom_vcp_mappings")] + public List CustomVcpMappings { get; set; } + } +} diff --git a/src/settings-ui/Settings.UI.Library/PowerDisplaySettings.cs b/src/settings-ui/Settings.UI.Library/PowerDisplaySettings.cs new file mode 100644 index 0000000000..f9fc8df5fd --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/PowerDisplaySettings.cs @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using System.Text.Json.Serialization; + +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library.Helpers; +using Microsoft.PowerToys.Settings.UI.Library.Interfaces; + +namespace Microsoft.PowerToys.Settings.UI.Library +{ + public class PowerDisplaySettings : BasePTModuleSettings, ISettingsConfig, IHotkeyConfig + { + public const string ModuleName = "PowerDisplay"; + + [JsonPropertyName("properties")] + public PowerDisplayProperties Properties { get; set; } + + public PowerDisplaySettings() + { + Properties = new PowerDisplayProperties(); + Version = "1"; + Name = ModuleName; + } + + public string GetModuleName() + => Name; + + // This can be utilized in the future if the settings.json file is to be modified/deleted. + public bool UpgradeSettingsConfiguration() + => false; + + public ModuleType GetModuleType() => ModuleType.PowerDisplay; + + public HotkeyAccessor[] GetAllHotkeyAccessors() + { + var hotkeyAccessors = new List + { + new HotkeyAccessor( + () => Properties.ActivationShortcut, + value => Properties.ActivationShortcut = value ?? Properties.DefaultActivationShortcut, + "Activation_Shortcut"), + }; + + return hotkeyAccessors.ToArray(); + } + } +} diff --git a/src/settings-ui/Settings.UI.Library/Settings.UI.Library.csproj b/src/settings-ui/Settings.UI.Library/Settings.UI.Library.csproj index 7c5d6a1bc3..f89bf76009 100644 --- a/src/settings-ui/Settings.UI.Library/Settings.UI.Library.csproj +++ b/src/settings-ui/Settings.UI.Library/Settings.UI.Library.csproj @@ -24,6 +24,7 @@ + diff --git a/src/settings-ui/Settings.UI.Library/SettingsSerializationContext.cs b/src/settings-ui/Settings.UI.Library/SettingsSerializationContext.cs index 2fab97c538..366f98cd8b 100644 --- a/src/settings-ui/Settings.UI.Library/SettingsSerializationContext.cs +++ b/src/settings-ui/Settings.UI.Library/SettingsSerializationContext.cs @@ -2,7 +2,9 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Collections.Generic; using System.Text.Json.Serialization; +using PowerDisplay.Common.Models; using SettingsUILibrary = Settings.UI.Library; using SettingsUILibraryHelpers = Settings.UI.Library.Helpers; @@ -65,6 +67,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library [JsonSerializable(typeof(NewPlusSettings))] [JsonSerializable(typeof(PeekSettings))] [JsonSerializable(typeof(PowerAccentSettings))] + [JsonSerializable(typeof(PowerDisplaySettings))] [JsonSerializable(typeof(PowerLauncherSettings))] [JsonSerializable(typeof(PowerOcrSettings))] [JsonSerializable(typeof(PowerPreviewSettings))] @@ -102,6 +105,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library [JsonSerializable(typeof(PeekProperties))] [JsonSerializable(typeof(SettingsUILibrary.PeekPreviewSettings))] [JsonSerializable(typeof(PowerAccentProperties))] + [JsonSerializable(typeof(PowerDisplayProperties))] [JsonSerializable(typeof(PowerLauncherProperties))] [JsonSerializable(typeof(PowerOcrProperties))] [JsonSerializable(typeof(PowerPreviewProperties))] @@ -134,13 +138,33 @@ namespace Microsoft.PowerToys.Settings.UI.Library [JsonSerializable(typeof(AdvancedPasteAdditionalAction))] [JsonSerializable(typeof(AdvancedPastePasteAsFileAction))] [JsonSerializable(typeof(AdvancedPasteTranscodeAction))] - [JsonSerializable(typeof(PasteAIConfiguration))] - [JsonSerializable(typeof(PasteAIProviderDefinition))] [JsonSerializable(typeof(ImageResizerSizes))] [JsonSerializable(typeof(ImageResizerCustomSizeProperty))] [JsonSerializable(typeof(KeyboardKeysProperty))] + [JsonSerializable(typeof(MonitorInfo))] + [JsonSerializable(typeof(PowerDisplayActionMessage))] + [JsonSerializable(typeof(PowerDisplayActionMessage.ActionData))] + [JsonSerializable(typeof(PowerDisplayActionMessage.PowerDisplayAction))] + [JsonSerializable(typeof(VcpCodeDisplayInfo))] + [JsonSerializable(typeof(VcpValueInfo))] + [JsonSerializable(typeof(List))] + [JsonSerializable(typeof(List))] + [JsonSerializable(typeof(List))] + [JsonSerializable(typeof(List))] [JsonSerializable(typeof(SettingsUILibraryHelpers.SearchLocation))] + // AdvancedPaste AI Provider Types (for AOT compatibility) + [JsonSerializable(typeof(PasteAIConfiguration))] + [JsonSerializable(typeof(PasteAIProviderDefinition))] + [JsonSerializable(typeof(System.Collections.ObjectModel.ObservableCollection))] + + // PowerDisplay Profile Types (for AOT compatibility) + [JsonSerializable(typeof(PowerDisplayProfile))] + [JsonSerializable(typeof(List))] + [JsonSerializable(typeof(PowerDisplayProfiles))] + [JsonSerializable(typeof(ProfileMonitorSetting))] + [JsonSerializable(typeof(List))] + // IPC Send Message Wrapper Classes (Snd*) [JsonSerializable(typeof(SndAwakeSettings))] [JsonSerializable(typeof(SndCursorWrapSettings))] diff --git a/src/settings-ui/Settings.UI.Library/VcpCodeDisplayInfo.cs b/src/settings-ui/Settings.UI.Library/VcpCodeDisplayInfo.cs new file mode 100644 index 0000000000..5edaaa72b2 --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/VcpCodeDisplayInfo.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Text.Json.Serialization; + +namespace Microsoft.PowerToys.Settings.UI.Library +{ + /// + /// Formatted VCP code display information + /// + public class VcpCodeDisplayInfo + { + [JsonPropertyName("code")] + public string Code { get; set; } = string.Empty; + + [JsonPropertyName("title")] + public string Title { get; set; } = string.Empty; + + [JsonPropertyName("values")] + public string Values { get; set; } = string.Empty; + + [JsonPropertyName("hasValues")] + public bool HasValues { get; set; } + + [JsonPropertyName("valueList")] + public System.Collections.Generic.List ValueList { get; set; } = new System.Collections.Generic.List(); + } +} diff --git a/src/settings-ui/Settings.UI.Library/VcpValueInfo.cs b/src/settings-ui/Settings.UI.Library/VcpValueInfo.cs new file mode 100644 index 0000000000..53dff29f33 --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/VcpValueInfo.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Text.Json.Serialization; + +namespace Microsoft.PowerToys.Settings.UI.Library +{ + /// + /// Individual VCP value information + /// + public class VcpValueInfo + { + [JsonPropertyName("value")] + public string Value { get; set; } = string.Empty; + + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + } +} diff --git a/src/settings-ui/Settings.UI.Library/ZoomItProperties.cs b/src/settings-ui/Settings.UI.Library/ZoomItProperties.cs index 1bca1b573a..6c6535801a 100644 --- a/src/settings-ui/Settings.UI.Library/ZoomItProperties.cs +++ b/src/settings-ui/Settings.UI.Library/ZoomItProperties.cs @@ -89,6 +89,8 @@ namespace Microsoft.PowerToys.Settings.UI.Library public StringProperty RecordFormat { get; set; } + public BoolProperty CaptureSystemAudio { get; set; } + public BoolProperty CaptureAudio { get; set; } public StringProperty MicrophoneDeviceId { get; set; } diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/PowerDisplay.png b/src/settings-ui/Settings.UI/Assets/Settings/Icons/PowerDisplay.png new file mode 100644 index 0000000000..0991f565ee Binary files /dev/null and b/src/settings-ui/Settings.UI/Assets/Settings/Icons/PowerDisplay.png differ diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Modules/OOBE/PowerDisplay.gif b/src/settings-ui/Settings.UI/Assets/Settings/Modules/OOBE/PowerDisplay.gif new file mode 100644 index 0000000000..8da2aaa14e Binary files /dev/null and b/src/settings-ui/Settings.UI/Assets/Settings/Modules/OOBE/PowerDisplay.gif differ diff --git a/src/settings-ui/Settings.UI/Converters/HotkeySettingsToLocalizedStringConverter.cs b/src/settings-ui/Settings.UI/Converters/HotkeySettingsToLocalizedStringConverter.cs new file mode 100644 index 0000000000..c092525c75 --- /dev/null +++ b/src/settings-ui/Settings.UI/Converters/HotkeySettingsToLocalizedStringConverter.cs @@ -0,0 +1,31 @@ +ο»Ώ// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Windows; +using Microsoft.PowerToys.Settings.UI.Helpers; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.UI.Xaml.Data; +using Microsoft.Windows.ApplicationModel.Resources; + +namespace Microsoft.PowerToys.Settings.UI.Converters +{ + public partial class HotkeySettingsToLocalizedStringConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, string language) + { + if (value is HotkeySettings keySettings && parameter is string resourceKey) + { + return string.Format(System.Globalization.CultureInfo.CurrentCulture, ResourceLoaderInstance.ResourceLoader.GetString(resourceKey), keySettings.ToString()); + } + + return string.Empty; + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + throw new NotImplementedException(); + } + } +} diff --git a/src/settings-ui/Settings.UI/Converters/ZoomItOpacitySliderConverter.cs b/src/settings-ui/Settings.UI/Converters/ZoomItOpacitySliderConverter.cs new file mode 100644 index 0000000000..e82211c886 --- /dev/null +++ b/src/settings-ui/Settings.UI/Converters/ZoomItOpacitySliderConverter.cs @@ -0,0 +1,24 @@ +// 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 Microsoft.UI.Xaml.Data; + +namespace Microsoft.PowerToys.Settings.UI.Converters +{ + public sealed partial class ZoomItOpacitySliderConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, string language) + { + // Slider value is 1-100, display as percentage + int percentage = System.Convert.ToInt32((double)value); + return $"{percentage}%"; + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + throw new NotImplementedException(); + } + } +} diff --git a/src/settings-ui/Settings.UI/Helpers/ModuleGpoHelper.cs b/src/settings-ui/Settings.UI/Helpers/ModuleGpoHelper.cs index 9517c91b21..18a17937dc 100644 --- a/src/settings-ui/Settings.UI/Helpers/ModuleGpoHelper.cs +++ b/src/settings-ui/Settings.UI/Helpers/ModuleGpoHelper.cs @@ -44,6 +44,7 @@ namespace Microsoft.PowerToys.Settings.UI.Helpers case ModuleType.MeasureTool: return GPOWrapper.GetConfiguredScreenRulerEnabledValue(); case ModuleType.ShortcutGuide: return GPOWrapper.GetConfiguredShortcutGuideEnabledValue(); case ModuleType.PowerOCR: return GPOWrapper.GetConfiguredTextExtractorEnabledValue(); + case ModuleType.PowerDisplay: return GPOWrapper.GetConfiguredPowerDisplayEnabledValue(); case ModuleType.ZoomIt: return GPOWrapper.GetConfiguredZoomItEnabledValue(); default: return GpoRuleConfigured.Unavailable; } @@ -83,6 +84,7 @@ namespace Microsoft.PowerToys.Settings.UI.Helpers ModuleType.MeasureTool => typeof(MeasureToolPage), ModuleType.ShortcutGuide => typeof(ShortcutGuidePage), ModuleType.PowerOCR => typeof(PowerOcrPage), + ModuleType.PowerDisplay => typeof(PowerDisplayPage), ModuleType.ZoomIt => typeof(ZoomItPage), _ => typeof(DashboardPage), // never called, all values listed above }; diff --git a/src/settings-ui/Settings.UI/OOBE/Enums/PowerToysModules.cs b/src/settings-ui/Settings.UI/OOBE/Enums/PowerToysModules.cs index d14c95fcad..93c1cc3e07 100644 --- a/src/settings-ui/Settings.UI/OOBE/Enums/PowerToysModules.cs +++ b/src/settings-ui/Settings.UI/OOBE/Enums/PowerToysModules.cs @@ -33,6 +33,7 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Enums Hosts, Workspaces, RegistryPreview, + PowerDisplay, NewPlus, ZoomIt, } diff --git a/src/settings-ui/Settings.UI/PowerToys.Settings.csproj b/src/settings-ui/Settings.UI/PowerToys.Settings.csproj index 7b1bdd378d..45650ecb26 100644 --- a/src/settings-ui/Settings.UI/PowerToys.Settings.csproj +++ b/src/settings-ui/Settings.UI/PowerToys.Settings.csproj @@ -131,6 +131,7 @@ + @@ -184,6 +185,9 @@ Always + + MSBuild:Compile + MSBuild:Compile diff --git a/src/settings-ui/Settings.UI/SerializationContext/SourceGenerationContextContext.cs b/src/settings-ui/Settings.UI/SerializationContext/SourceGenerationContextContext.cs index d8c53aac76..36fd08ecd2 100644 --- a/src/settings-ui/Settings.UI/SerializationContext/SourceGenerationContextContext.cs +++ b/src/settings-ui/Settings.UI/SerializationContext/SourceGenerationContextContext.cs @@ -19,6 +19,7 @@ namespace Microsoft.PowerToys.Settings.UI.SerializationContext; [JsonSerializable(typeof(AlwaysOnTopSettings))] [JsonSerializable(typeof(ColorPickerSettings))] [JsonSerializable(typeof(CropAndLockSettings))] +[JsonSerializable(typeof(CursorWrapSettings))] [JsonSerializable(typeof(Dictionary>))] [JsonSerializable(typeof(FileLocksmithSettings))] [JsonSerializable(typeof(FindMyMouseSettings))] @@ -34,11 +35,16 @@ namespace Microsoft.PowerToys.Settings.UI.SerializationContext; [JsonSerializable(typeof(PowerLauncherSettings))] [JsonSerializable(typeof(PowerOcrSettings))] [JsonSerializable(typeof(PowerOcrSettings))] +[JsonSerializable(typeof(PowerDisplaySettings))] [JsonSerializable(typeof(RegistryPreviewSettings))] [JsonSerializable(typeof(ShortcutConflictProperties))] [JsonSerializable(typeof(ShortcutGuideSettings))] [JsonSerializable(typeof(WINDOWPLACEMENT))] [JsonSerializable(typeof(WorkspacesSettings))] +[JsonSerializable(typeof(ZoomItSettings))] +[JsonSerializable(typeof(PasteAIConfiguration))] +[JsonSerializable(typeof(PasteAIProviderDefinition))] +[JsonSerializable(typeof(System.Collections.ObjectModel.ObservableCollection))] public sealed partial class SourceGenerationContextContext : JsonSerializerContext { } diff --git a/src/settings-ui/Settings.UI/SettingsXAML/App.xaml b/src/settings-ui/Settings.UI/SettingsXAML/App.xaml index f63ccdf3a6..54b1e1a02f 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/App.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/App.xaml @@ -4,6 +4,7 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:controls="using:Microsoft.PowerToys.Settings.UI.Controls" xmlns:converters="using:Microsoft.PowerToys.Settings.UI.Converters" + xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" xmlns:tkconverters="using:CommunityToolkit.WinUI.Converters"> @@ -18,7 +19,7 @@ - + @@ -81,9 +82,6 @@ - - - diff --git a/src/settings-ui/Settings.UI/SettingsXAML/App.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/App.xaml.cs index 7f14f1809e..026b142e7c 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/App.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/App.xaml.cs @@ -415,6 +415,7 @@ namespace Microsoft.PowerToys.Settings.UI case "Workspaces": return typeof(WorkspacesPage); case "CmdPal": return typeof(CmdPalPage); case "ZoomIt": return typeof(ZoomItPage); + case "PowerDisplay": return typeof(PowerDisplayPage); default: // Fallback to Dashboard Debug.Assert(false, "Unexpected SettingsWindow argument value"); diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutWithTextLabelControl.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutWithTextLabelControl.xaml index bc46c9d17e..719091a787 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutWithTextLabelControl.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutWithTextLabelControl.xaml @@ -1,58 +1,67 @@ -ο»Ώ + + xmlns:local="using:Microsoft.PowerToys.Settings.UI.Controls" + xmlns:tk="using:CommunityToolkit.WinUI" + xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutWithTextLabelControl.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutWithTextLabelControl.xaml.cs index c3829e3984..7e4d31c28b 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutWithTextLabelControl.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutWithTextLabelControl.xaml.cs @@ -1,15 +1,15 @@ -ο»Ώ// Copyright (c) Microsoft Corporation +// 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 CommunityToolkit.WinUI.Controls; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; namespace Microsoft.PowerToys.Settings.UI.Controls { - public sealed partial class ShortcutWithTextLabelControl : UserControl + public sealed partial class ShortcutWithTextLabelControl : Control { public string Text { @@ -27,26 +27,47 @@ namespace Microsoft.PowerToys.Settings.UI.Controls public static readonly DependencyProperty KeysProperty = DependencyProperty.Register(nameof(Keys), typeof(List), typeof(ShortcutWithTextLabelControl), new PropertyMetadata(default(string))); - public LabelPlacement LabelPlacement + public Placement LabelPlacement { - get { return (LabelPlacement)GetValue(LabelPlacementProperty); } + get { return (Placement)GetValue(LabelPlacementProperty); } set { SetValue(LabelPlacementProperty, value); } } - public static readonly DependencyProperty LabelPlacementProperty = DependencyProperty.Register(nameof(LabelPlacement), typeof(LabelPlacement), typeof(ShortcutWithTextLabelControl), new PropertyMetadata(defaultValue: LabelPlacement.After, OnIsLabelPlacementChanged)); + public static readonly DependencyProperty LabelPlacementProperty = DependencyProperty.Register(nameof(LabelPlacement), typeof(Placement), typeof(ShortcutWithTextLabelControl), new PropertyMetadata(defaultValue: Placement.After, OnIsLabelPlacementChanged)); + + public MarkdownConfig MarkdownConfig + { + get { return (MarkdownConfig)GetValue(MarkdownConfigProperty); } + set { SetValue(MarkdownConfigProperty, value); } + } + + public static readonly DependencyProperty MarkdownConfigProperty = DependencyProperty.Register(nameof(MarkdownConfig), typeof(MarkdownConfig), typeof(ShortcutWithTextLabelControl), new PropertyMetadata(new MarkdownConfig())); + + public Style KeyVisualStyle + { + get { return (Style)GetValue(KeyVisualStyleProperty); } + set { SetValue(KeyVisualStyleProperty, value); } + } + + public static readonly DependencyProperty KeyVisualStyleProperty = DependencyProperty.Register(nameof(KeyVisualStyle), typeof(Style), typeof(ShortcutWithTextLabelControl), new PropertyMetadata(default(Style))); public ShortcutWithTextLabelControl() { - this.InitializeComponent(); + DefaultStyleKey = typeof(ShortcutWithTextLabelControl); + } + + protected override void OnApplyTemplate() + { + base.OnApplyTemplate(); } private static void OnIsLabelPlacementChanged(DependencyObject d, DependencyPropertyChangedEventArgs newValue) { if (d is ShortcutWithTextLabelControl labelControl) { - if (labelControl.LabelPlacement == LabelPlacement.Before) + if (labelControl.LabelPlacement == Placement.Before) { - VisualStateManager.GoToState(labelControl, "LabelBefore", true); + VisualStateManager.GoToState(labelControl, "LabelBefore", true); } else { @@ -54,11 +75,11 @@ namespace Microsoft.PowerToys.Settings.UI.Controls } } } - } - public enum LabelPlacement - { - Before, - After, + public enum Placement + { + Before, + After, + } } } diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeColorPicker.xaml b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeColorPicker.xaml index 115bdd417a..1700a3b05f 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeColorPicker.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeColorPicker.xaml @@ -21,6 +21,7 @@ - - - + + + + + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/LightSwitchPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/LightSwitchPage.xaml.cs index bd6d01edde..b366fd7a2b 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/LightSwitchPage.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/LightSwitchPage.xaml.cs @@ -8,6 +8,7 @@ using System.IO; using System.IO.Abstractions; using System.Linq; using System.Threading.Tasks; +using Common.UI; using ManagedCommon; using Microsoft.PowerToys.Settings.UI.Helpers; using Microsoft.PowerToys.Settings.UI.Library; @@ -380,5 +381,10 @@ namespace Microsoft.PowerToys.Settings.UI.Views this.LocationWarningBar.Visibility = Visibility.Visible; } } + + private void NavigatePowerDisplaySettings_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) + { + ShellPage.Navigate(typeof(PowerDisplayPage)); + } } } diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/MouseUtilsPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/MouseUtilsPage.xaml index 567c4246eb..1ded2db636 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/MouseUtilsPage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/MouseUtilsPage.xaml @@ -47,6 +47,16 @@ + + + + + + + + + + diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerDisplayPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerDisplayPage.xaml new file mode 100644 index 0000000000..899295ec66 --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerDisplayPage.xaml @@ -0,0 +1,303 @@ +ο»Ώ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerDisplayPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerDisplayPage.xaml.cs new file mode 100644 index 0000000000..d82746faf6 --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerDisplayPage.xaml.cs @@ -0,0 +1,277 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using CommunityToolkit.WinUI.Controls; +using Microsoft.PowerToys.Settings.UI.Helpers; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.PowerToys.Settings.UI.ViewModels; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using PowerDisplay.Common.Models; +using PowerDisplay.Common.Utils; +using Windows.ApplicationModel.DataTransfer; + +namespace Microsoft.PowerToys.Settings.UI.Views +{ + public sealed partial class PowerDisplayPage : NavigablePage, IRefreshablePage + { + private PowerDisplayViewModel ViewModel { get; set; } + + public PowerDisplayPage() + { + var settingsUtils = SettingsUtils.Default; + ViewModel = new PowerDisplayViewModel( + settingsUtils, + SettingsRepository.GetInstance(settingsUtils), + SettingsRepository.GetInstance(settingsUtils), + ShellPage.SendDefaultIPCMessage); + DataContext = ViewModel; + InitializeComponent(); + Loaded += (s, e) => ViewModel.OnPageLoaded(); + } + + public void RefreshEnabledState() + { + ViewModel.RefreshEnabledState(); + } + + private void CopyVcpCodes_Click(object sender, RoutedEventArgs e) + { + if (sender is Button button && button.Tag is MonitorInfo monitor) + { + var vcpText = monitor.GetVcpCodesAsText(); + var dataPackage = new DataPackage(); + dataPackage.SetText(vcpText); + Clipboard.SetContent(dataPackage); + } + } + + // Profile button event handlers + private void ProfileButton_Click(object sender, RoutedEventArgs e) + { + if (sender is Button button && button.Tag is PowerDisplayProfile profile) + { + ViewModel.ApplyProfile(profile); + } + } + + private async void AddProfileButton_Click(object sender, RoutedEventArgs e) + { + if (ViewModel.Monitors == null || ViewModel.Monitors.Count == 0) + { + return; + } + + var defaultName = GenerateDefaultProfileName(); + var dialog = new ProfileEditorDialog(ViewModel.Monitors, defaultName); + dialog.XamlRoot = this.XamlRoot; + + var result = await dialog.ShowAsync(); + + if (result == ContentDialogResult.Primary && dialog.ResultProfile != null) + { + ViewModel.CreateProfile(dialog.ResultProfile); + } + } + + private async void EditProfile_Click(object sender, RoutedEventArgs e) + { + var menuItem = sender as MenuFlyoutItem; + if (menuItem?.Tag is PowerDisplayProfile profile) + { + var dialog = new ProfileEditorDialog(ViewModel.Monitors, profile.Name); + dialog.XamlRoot = this.XamlRoot; + + // Pre-fill with existing profile settings + dialog.PreFillProfile(profile); + + var result = await dialog.ShowAsync(); + + if (result == ContentDialogResult.Primary && dialog.ResultProfile != null) + { + ViewModel.UpdateProfile(profile.Name, dialog.ResultProfile); + } + } + } + + private async void DeleteProfile_Click(object sender, RoutedEventArgs e) + { + var menuItem = sender as MenuFlyoutItem; + if (menuItem?.Tag is PowerDisplayProfile profile) + { + var resourceLoader = ResourceLoaderInstance.ResourceLoader; + var dialog = new ContentDialog + { + XamlRoot = this.XamlRoot, + Title = resourceLoader.GetString("PowerDisplay_DeleteProfile_Title"), + Content = string.Format(System.Globalization.CultureInfo.CurrentCulture, resourceLoader.GetString("PowerDisplay_DeleteProfile_Content"), profile.Name), + PrimaryButtonText = resourceLoader.GetString("PowerDisplay_DeleteProfile_PrimaryButton"), + CloseButtonText = resourceLoader.GetString("PowerDisplay_Dialog_Cancel"), + DefaultButton = ContentDialogButton.Close, + }; + + var result = await dialog.ShowAsync(); + + if (result == ContentDialogResult.Primary) + { + ViewModel.DeleteProfile(profile.Name); + } + } + } + + private string GenerateDefaultProfileName() + { + // Use shared ProfileHelper for consistent profile name generation + var existingNames = ViewModel.Profiles.Select(p => p.Name).ToHashSet(); + var resourceLoader = ResourceLoaderInstance.ResourceLoader; + var baseName = resourceLoader.GetString("PowerDisplay_Profile_DefaultBaseName"); + return ProfileHelper.GenerateUniqueProfileName(existingNames, baseName); + } + + // Custom VCP Mapping event handlers + private async void AddCustomMapping_Click(object sender, RoutedEventArgs e) + { + var dialog = new CustomVcpMappingEditorDialog(ViewModel.Monitors); + dialog.XamlRoot = this.XamlRoot; + + var result = await dialog.ShowAsync(); + + if (result == ContentDialogResult.Primary && dialog.ResultMapping != null) + { + ViewModel.AddCustomVcpMapping(dialog.ResultMapping); + } + } + + private async void EditCustomMapping_Click(object sender, RoutedEventArgs e) + { + if (sender is not Button button || button.Tag is not CustomVcpValueMapping mapping) + { + return; + } + + var dialog = new CustomVcpMappingEditorDialog(ViewModel.Monitors); + dialog.XamlRoot = this.XamlRoot; + dialog.PreFillMapping(mapping); + + var result = await dialog.ShowAsync(); + + if (result == ContentDialogResult.Primary && dialog.ResultMapping != null) + { + ViewModel.UpdateCustomVcpMapping(mapping, dialog.ResultMapping); + } + } + + private async void DeleteCustomMapping_Click(object sender, RoutedEventArgs e) + { + if (sender is not Button button || button.Tag is not CustomVcpValueMapping mapping) + { + return; + } + + var resourceLoader = ResourceLoaderInstance.ResourceLoader; + var dialog = new ContentDialog + { + XamlRoot = this.XamlRoot, + Title = resourceLoader.GetString("PowerDisplay_CustomMapping_Delete_Title"), + Content = resourceLoader.GetString("PowerDisplay_CustomMapping_Delete_Message"), + PrimaryButtonText = resourceLoader.GetString("Yes"), + CloseButtonText = resourceLoader.GetString("No"), + DefaultButton = ContentDialogButton.Close, + }; + + var result = await dialog.ShowAsync(); + + if (result == ContentDialogResult.Primary) + { + ViewModel.DeleteCustomVcpMapping(mapping); + } + } + + // Flag to prevent reentrant handling during programmatic checkbox changes + private bool _isRestoringColorTempCheckbox; + + private async void EnableColorTemperature_Click(object sender, RoutedEventArgs e) + { + // Skip if we're programmatically restoring the checkbox state + if (_isRestoringColorTempCheckbox) + { + return; + } + + if (sender is not CheckBox checkBox || checkBox.Tag is not MonitorInfo monitor) + { + return; + } + + // Only show warning when enabling (checking the box) + if (checkBox.IsChecked != true) + { + return; + } + + // Show confirmation dialog with color temperature warning + var resourceLoader = ResourceLoaderInstance.ResourceLoader; + var dialog = new ContentDialog + { + XamlRoot = this.XamlRoot, + Title = resourceLoader.GetString("PowerDisplay_ColorTemperature_WarningTitle"), + Content = new StackPanel + { + Spacing = 12, + Children = + { + new TextBlock + { + Text = resourceLoader.GetString("PowerDisplay_ColorTemperature_WarningHeader"), + FontWeight = Microsoft.UI.Text.FontWeights.Bold, + Foreground = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["SystemFillColorCriticalBrush"], + TextWrapping = TextWrapping.Wrap, + }, + new TextBlock + { + Text = resourceLoader.GetString("PowerDisplay_ColorTemperature_WarningDescription"), + TextWrapping = TextWrapping.Wrap, + }, + new TextBlock + { + Text = resourceLoader.GetString("PowerDisplay_ColorTemperature_WarningList"), + TextWrapping = TextWrapping.Wrap, + Margin = new Thickness(20, 0, 0, 0), + }, + new TextBlock + { + Text = resourceLoader.GetString("PowerDisplay_ColorTemperature_WarningConfirm"), + FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, + TextWrapping = TextWrapping.Wrap, + }, + }, + }, + PrimaryButtonText = resourceLoader.GetString("PowerDisplay_ColorTemperature_EnableButton"), + CloseButtonText = resourceLoader.GetString("PowerDisplay_Dialog_Cancel"), + DefaultButton = ContentDialogButton.Close, + }; + + var result = await dialog.ShowAsync(); + + if (result != ContentDialogResult.Primary) + { + // User cancelled: revert checkbox to unchecked + _isRestoringColorTempCheckbox = true; + try + { + checkBox.IsChecked = false; + monitor.EnableColorTemperature = false; + } + finally + { + _isRestoringColorTempCheckbox = false; + } + } + } + } +} diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/ProfileEditorDialog.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/ProfileEditorDialog.xaml new file mode 100644 index 0000000000..bd0f0269c8 --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/ProfileEditorDialog.xaml @@ -0,0 +1,132 @@ + + + + + 0 + 0 + 0,8,0,8 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/ProfileEditorDialog.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/ProfileEditorDialog.xaml.cs new file mode 100644 index 0000000000..00baece4dc --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/ProfileEditorDialog.xaml.cs @@ -0,0 +1,104 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +#nullable enable + +using System.Collections.ObjectModel; +using System.Linq; +using Microsoft.PowerToys.Settings.UI.Helpers; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.PowerToys.Settings.UI.ViewModels; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using PowerDisplay.Common.Models; + +namespace Microsoft.PowerToys.Settings.UI.Views +{ + /// + /// Dialog for creating/editing PowerDisplay profiles + /// + public sealed partial class ProfileEditorDialog : ContentDialog + { + public ProfileEditorViewModel ViewModel { get; private set; } + + public PowerDisplayProfile? ResultProfile { get; private set; } + + public ProfileEditorDialog(ObservableCollection availableMonitors, string defaultName = "") + { + this.InitializeComponent(); + ViewModel = new ProfileEditorViewModel(availableMonitors, defaultName); + + // Set localized strings for ContentDialog + var resourceLoader = ResourceLoaderInstance.ResourceLoader; + Title = resourceLoader.GetString("PowerDisplay_ProfileEditor_Title"); + PrimaryButtonText = resourceLoader.GetString("PowerDisplay_Dialog_Save"); + CloseButtonText = resourceLoader.GetString("PowerDisplay_Dialog_Cancel"); + } + + private void ContentDialog_PrimaryButtonClick(ContentDialog sender, ContentDialogButtonClickEventArgs args) + { + if (ViewModel.CanSave) + { + ResultProfile = ViewModel.CreateProfile(); + } + } + + private void ContentDialog_CloseButtonClick(ContentDialog sender, ContentDialogButtonClickEventArgs args) + { + ResultProfile = null; + } + + /// + /// Pre-fill the dialog with existing profile data + /// + public void PreFillProfile(PowerDisplayProfile profile) + { + if (profile == null || ViewModel == null) + { + return; + } + + // Set profile name + ViewModel.ProfileName = profile.Name; + + // Pre-fill monitor settings from existing profile + foreach (var monitorSetting in profile.MonitorSettings) + { + var monitorItem = ViewModel.Monitors.FirstOrDefault(m => m.Monitor.Id == monitorSetting.MonitorId); + if (monitorItem != null) + { + monitorItem.IsSelected = true; + + // Set brightness if included in profile + if (monitorSetting.Brightness.HasValue) + { + monitorItem.IncludeBrightness = true; + monitorItem.Brightness = monitorSetting.Brightness.Value; + } + + // Set color temperature if included in profile + if (monitorSetting.ColorTemperatureVcp.HasValue) + { + monitorItem.IncludeColorTemperature = true; + monitorItem.ColorTemperature = monitorSetting.ColorTemperatureVcp.Value; + } + + // Set contrast if included in profile + if (monitorSetting.Contrast.HasValue) + { + monitorItem.IncludeContrast = true; + monitorItem.Contrast = monitorSetting.Contrast.Value; + } + + // Set volume if included in profile + if (monitorSetting.Volume.HasValue) + { + monitorItem.IncludeVolume = true; + monitorItem.Volume = monitorSetting.Volume.Value; + } + } + } + } + } +} diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/ShellPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/ShellPage.xaml index aae80d05e7..6a64fdea83 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/ShellPage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/ShellPage.xaml @@ -179,6 +179,9 @@ AutomationProperties.AutomationId="SystemToolsNavItem" Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/SystemTools.png}" SelectsOnInvoked="False"> + + + + + + + + - + + + + - - - - - - - - - - - - - - + HeaderIcon="{ui:FontIcon Glyph=}" + IsExpanded="True"> + + + + + + + + + + + + + + + + + + - - - + HeaderIcon="{ui:FontIcon Glyph=}" + IsExpanded="True"> + + + + + + + + + - - - + HeaderIcon="{ui:FontIcon Glyph=}" + IsExpanded="True"> + + + + + + + + + - - + + - +